Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Fixed an HTTP 400 that killed every deep-interview session on the `google-antigravity` provider before the first assistant turn. The Round-0 topology `ask` schema pinned `round` with `z.literal(0)`, which zod serializes as `const: 0` and the Cloud Code Assist normalizer rewrites to a numeric `enum: [0]` — a shape CCA rejects (`TYPE_STRING`). `round` is now pinned with an integer range `[0, 0]` instead, so the wire schema carries `type: integer` with the bounds spilled into the description (the same treatment `ambiguity` already gets) and no numeric enum remains. Runtime contract unchanged: only `0` validates (#4606).
- The terminal-app integration docs now cite the upstream work that backs each support rating: Gajae Code is proposed for Paseo's in-app ACP provider catalog ([getpaseo/paseo#3471](https://github.com/getpaseo/paseo/pull/3471)) and for Orca's built-in agent registry ([stablyai/orca#15025](https://github.com/stablyai/orca/pull/15025)), while T3 Code has no GJC harness and the integration shape is under discussion upstream ([pingdotgg/t3code#7290](https://github.com/pingdotgg/t3code/discussions/7290)).
- The README controller section is now generic and SDK-first: it is titled for OpenClaw / Hermes / Grokbot / your own bot, and the copy-paste bootstrap prompt drives GJC through the broker-bound `gjc sdk session` CLI and the bundled [`sdk-skills/`](https://github.com/Yeachan-Heo/gajae-code/tree/main/sdk-skills) procedures instead of the Coordinator MCP setup. The prompt now names the real surface: `list`/`inspect`/`raw query` discovery, `session.create|fork|resume|close` lifecycle with an idempotency key, `send --op-ref` plus `status` reconciliation (one fresh op-ref per logical prompt; `unknown` is uncertainty, not proof of non-execution), `tail --until-idle`, and the `ask.answer` / `workflow.gate_answer` control allowlist. Coordinator MCP is kept as the pointer for event-driven fan-out across worktrees.
- Every session start under a non-writable cwd (e.g. a Windows console defaulting to `C:\Windows\System32`) no longer dies with an uncaught `EPERM` before any output: `FileGateStore.beginRuntimeInstance` no longer flushes at construction when the store holds no gates and no counters (the runtime instance id rides along with the first real mutation, preserving the documented lazy first-write contract), and `flushState` now runs `mkdirSync` inside the typed write boundary so a genuinely unwritable directory surfaces as `GateStoreWriteError` instead of a raw `ErrnoException` escaping the store abstraction (#4568).

## [0.14.0] - 2026-08-17
- Documented how to run GJC inside external agent shells. `README.md` gains a support-rated integration table for [Paseo](https://paseo.sh) (★★★★★ — `gjc setup paseo` writes a conformance-tested ACP provider), [Orca](https://onorca.dev) (★★★★ — GJC runs as a custom CLI agent per worktree), and [T3 Code](https://t3.codes) (★★★ experimental — no GJC harness exists upstream yet), and [`docs/terminal-app-integrations.md`](../../docs/terminal-app-integrations.md) carries the per-host setup, verification, cancel-semantics, and troubleshooting detail. The T3 Code row is deliberately marked unsupported rather than advertising an install command for a bridge that does not exist.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1041,10 +1041,10 @@ export class FileGateStore implements GateStore {
}
}
private flushState(next: FileState): void {
mkdirSync(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
let renamed = false;
try {
mkdirSync(path.dirname(this.filePath), { recursive: true });
const fd = openSync(tmp, "w");
try {
writeFileSync(fd, JSON.stringify(next, null, 2));
Expand Down Expand Up @@ -1125,6 +1125,15 @@ export class FileGateStore implements GateStore {
}
}
next.runtimeInstanceId = instanceId;
// Nothing to persist yet: an empty store carries no gates or counters a
// later process could recover or quarantine, so stamping the runtime
// instance id now would mkdir under the cwd at construction time and
// defeat the lazy first-write contract (#4568). Adopt it in memory so it
// rides along with the first real mutation instead.
if (Object.keys(next.gates).length === 0 && Object.keys(next.counters).length === 0) {
this.state = next;
return;
}
this.commit(next);
}
list(): PersistedGate[] {
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/test/sdk-workflow-gate-emitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,16 @@ describe("SDK ToolSession forwards getWorkflowGateEmitter", () => {
"workflow-gates.json",
);
try {
// #4568: construction must not mkdir under the cwd; the durable store
// materializes on the first persisted gate, proving the persistent
// session still uses the file-backed store.
expect(fs.existsSync(persistentGatePath)).toBe(false);
const gatePromise = persistentSession.getWorkflowGateEmitter()!.emitGate({
stage: "deep-interview",
kind: "question",
schema: { type: "string" },
});
gatePromise.catch(() => {});
expect(fs.existsSync(persistentGatePath)).toBe(true);
} finally {
await persistentSession.dispose();
Expand Down
85 changes: 83 additions & 2 deletions packages/coding-agent/test/workflow-gate-broker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
Expand All @@ -7,6 +8,7 @@ import type { GateContinuation } from "../src/modes/shared/agent-wire/workflow-g
import {
FileGateStore,
type GateAuditEvent,
GateStoreWriteError,
isUnsupportedWindowsDirectorySyncError,
MemoryGateStore,
WorkflowGateBroker,
Expand Down Expand Up @@ -491,7 +493,7 @@ describe("WorkflowGateBroker", () => {
let syncs = 0;
const store = new FileGateStore(file, () => {
syncs++;
if (syncs === 8) throw new Error("parent fsync failed after accepted rename");
if (syncs === 6) throw new Error("parent fsync failed after accepted rename");
});
const broker = new WorkflowGateBroker("run-uncertain-accepted", store, { advance: () => {} });
const gate = broker.openGate(
Expand All @@ -507,4 +509,83 @@ describe("WorkflowGateBroker", () => {
lifecycle: { reason: "continuation_owner_lost" },
});
});
it("does not mkdir at construction on a fresh empty store under a non-writable cwd (#4568)", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-fresh-"));
const storePath = path.join(dir, "workspace", ".gjc", "_session-s1", "state", "workflow-gates.json");
const broker = new WorkflowGateBroker(
"run-4568-fresh",
new FileGateStore(storePath),
{},
"8184568a-0000-4000-8000-000000000001",
);
expect(broker.listPendingGates()).toEqual([]);
expect(fs.existsSync(path.dirname(storePath))).toBe(false);

// The instance id rides along with the first real mutation.
const gate = broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } });
expect(fs.existsSync(storePath)).toBe(true);
const persisted = JSON.parse(readFileSync(storePath, "utf8")) as { runtimeInstanceId?: string };
expect(persisted.runtimeInstanceId).toBe("8184568a-0000-4000-8000-000000000001");
expect(new FileGateStore(storePath).get(gate.gate_id)).toMatchObject({
status: "quarantined",
ownerInstanceId: "8184568a-0000-4000-8000-000000000001",
});
});
it("surfaces an unwritable directory as a typed GateStoreWriteError instead of a raw errno (#4568)", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-write-"));
const storePath = path.join(dir, "workspace", ".gjc", "_session-s2", "state", "workflow-gates.json");
const broker = new WorkflowGateBroker(
"run-4568-write",
new FileGateStore(storePath),
{},
"8184568b-0000-4000-8000-000000000002",
);
const eperm = new Error("EPERM: operation not permitted, mkdir") as NodeJS.ErrnoException;
eperm.code = "EPERM";
const mkdirSync = vi.spyOn(fs, "mkdirSync").mockImplementation((() => {
throw eperm;
}) as typeof fs.mkdirSync);
try {
expect(() => broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } })).toThrow(
GateStoreWriteError,
);
} finally {
mkdirSync.mockRestore();
}
// After the typed failure nothing was committed: the first real write retries cleanly.
expect(fs.existsSync(storePath)).toBe(false);
const gate = broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } });
expect(new FileGateStore(storePath).get(gate.gate_id)).toMatchObject({ status: "quarantined" });
});
it("keeps quarantining prior-instance records at construction once state exists (#4568)", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-restart-"));
const file = path.join(dir, "gates.json");
const first = new WorkflowGateBroker(
"run-4568-restart",
new FileGateStore(file),
{},
"8184568c-0000-4000-8000-000000000003",
);
const gate = first.openGate(
{ stage: "ralplan", kind: "approval", schema: { type: "string" } },
liveContinuation(),
);
const before = JSON.parse(readFileSync(file, "utf8")) as { counters: Record<string, number> };
const second = new WorkflowGateBroker(
"run-4568-restart",
new FileGateStore(file),
{},
"8184568d-0000-4000-8000-000000000004",
);
expect(second.listGateDiagnostics()).toMatchObject([
{ gate_id: gate.gate_id, lifecycle: { reason: "orphaned_after_process_restart" } },
]);
// The restart rewrite keeps the committed counter and stamps the new instance id.
const after = JSON.parse(readFileSync(file, "utf8")) as {
counters: Record<string, number>;
runtimeInstanceId: string;
};
expect(after.counters.ralplan).toBe(before.counters.ralplan);
expect(after.runtimeInstanceId).toBe("8184568d-0000-4000-8000-000000000004");
});
});
Loading