@gajae-code/coding-agent 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/types/async/job-manager.d.ts +84 -2
- package/dist/types/commands/harness.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +6 -0
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/deep-interview/render-middleware.d.ts +5 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +6 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +24 -0
- package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
- package/dist/types/harness-control-plane/classifier.d.ts +13 -0
- package/dist/types/harness-control-plane/control-endpoint.d.ts +30 -0
- package/dist/types/harness-control-plane/finalize.d.ts +47 -0
- package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
- package/dist/types/harness-control-plane/operate.d.ts +35 -0
- package/dist/types/harness-control-plane/owner.d.ts +46 -0
- package/dist/types/harness-control-plane/preserve.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +88 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
- package/dist/types/harness-control-plane/seams.d.ts +21 -0
- package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
- package/dist/types/harness-control-plane/storage.d.ts +53 -0
- package/dist/types/harness-control-plane/types.d.ts +162 -0
- package/dist/types/hooks/skill-keywords.d.ts +2 -1
- package/dist/types/hooks/skill-state.d.ts +2 -29
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +8 -0
- package/dist/types/skill-state/active-state.d.ts +2 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
- package/dist/types/skill-state/workflow-state-contract.d.ts +24 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/types.d.ts +55 -3
- package/dist/types/tools/subagent.d.ts +11 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +298 -6
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/config-cli.ts +10 -2
- package/src/cli.ts +2 -0
- package/src/commands/harness.ts +592 -0
- package/src/commands/team.ts +36 -39
- package/src/config/settings-schema.ts +7 -0
- package/src/config/settings.ts +5 -0
- package/src/deep-interview/render-middleware.ts +366 -0
- package/src/defaults/gjc/skills/team/SKILL.md +47 -21
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/types.ts +6 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +40 -21
- package/src/gjc-runtime/goal-mode-request.ts +11 -3
- package/src/gjc-runtime/ralplan-runtime.ts +25 -10
- package/src/gjc-runtime/state-graph.ts +86 -0
- package/src/gjc-runtime/state-migrations.ts +132 -0
- package/src/gjc-runtime/state-renderer.ts +345 -0
- package/src/gjc-runtime/state-runtime.ts +733 -21
- package/src/gjc-runtime/state-validation.ts +49 -0
- package/src/gjc-runtime/state-writer.ts +718 -0
- package/src/gjc-runtime/team-runtime.ts +1083 -89
- package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
- package/src/gjc-runtime/workflow-manifest.ts +425 -0
- package/src/harness-control-plane/classifier.ts +128 -0
- package/src/harness-control-plane/control-endpoint.ts +137 -0
- package/src/harness-control-plane/finalize.ts +222 -0
- package/src/harness-control-plane/frame-mapper.ts +286 -0
- package/src/harness-control-plane/operate.ts +225 -0
- package/src/harness-control-plane/owner.ts +553 -0
- package/src/harness-control-plane/preserve.ts +102 -0
- package/src/harness-control-plane/receipts.ts +216 -0
- package/src/harness-control-plane/rpc-adapter.ts +276 -0
- package/src/harness-control-plane/seams.ts +39 -0
- package/src/harness-control-plane/session-lease.ts +388 -0
- package/src/harness-control-plane/state-machine.ts +97 -0
- package/src/harness-control-plane/storage.ts +257 -0
- package/src/harness-control-plane/types.ts +214 -0
- package/src/hooks/skill-keywords.ts +4 -2
- package/src/hooks/skill-state.ts +24 -41
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/assistant-message.ts +5 -1
- package/src/modes/components/hook-selector.ts +72 -2
- package/src/modes/controllers/event-controller.ts +71 -6
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/input-controller.ts +9 -1
- package/src/modes/controllers/selector-controller.ts +2 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/agents/executor.md +13 -0
- package/src/prompts/tools/subagent.md +33 -3
- package/src/sdk.ts +4 -0
- package/src/session/agent-session.ts +231 -33
- package/src/session/session-manager.ts +13 -1
- package/src/skill-state/active-state.ts +58 -65
- package/src/skill-state/deep-interview-mutation-guard.ts +91 -13
- package/src/skill-state/initial-phase.ts +2 -0
- package/src/skill-state/workflow-state-contract.ts +26 -0
- package/src/task/executor.ts +50 -8
- package/src/task/index.ts +120 -8
- package/src/task/render.ts +6 -3
- package/src/task/types.ts +56 -3
- package/src/tools/ask.ts +28 -7
- package/src/tools/subagent.ts +255 -64
|
@@ -31,7 +31,7 @@ export class InputController {
|
|
|
31
31
|
constructor(private ctx: InteractiveModeContext) {}
|
|
32
32
|
|
|
33
33
|
#abortInteractive(): Promise<void> {
|
|
34
|
-
return this.ctx.session.abort({ timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS });
|
|
34
|
+
return this.ctx.session.abort({ timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS, cause: "user_interrupt" });
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
setupKeyHandlers(): void {
|
|
@@ -568,6 +568,14 @@ export class InputController {
|
|
|
568
568
|
this.ctx.retryLoader.stop();
|
|
569
569
|
this.ctx.retryLoader = undefined;
|
|
570
570
|
}
|
|
571
|
+
if (this.ctx.retryCountdownTimer) {
|
|
572
|
+
clearInterval(this.ctx.retryCountdownTimer);
|
|
573
|
+
this.ctx.retryCountdownTimer = undefined;
|
|
574
|
+
}
|
|
575
|
+
if (this.ctx.retryEscapeHandler) {
|
|
576
|
+
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
577
|
+
this.ctx.retryEscapeHandler = undefined;
|
|
578
|
+
}
|
|
571
579
|
this.ctx.statusContainer.clear();
|
|
572
580
|
this.ctx.statusLine.dispose();
|
|
573
581
|
|
|
@@ -57,12 +57,13 @@ import { TreeSelectorComponent } from "../components/tree-selector";
|
|
|
57
57
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
58
58
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
59
59
|
|
|
60
|
-
const CALLBACK_SERVER_PROVIDERS = new Set<
|
|
60
|
+
const CALLBACK_SERVER_PROVIDERS = new Set<string>([
|
|
61
61
|
"anthropic",
|
|
62
62
|
"openai-codex",
|
|
63
63
|
"gitlab-duo",
|
|
64
64
|
"google-gemini-cli",
|
|
65
65
|
"google-antigravity",
|
|
66
|
+
"xai",
|
|
66
67
|
]);
|
|
67
68
|
|
|
68
69
|
const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
|
|
@@ -276,6 +276,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
276
276
|
}
|
|
277
277
|
autoCompactionEscapeHandler?: () => void;
|
|
278
278
|
retryEscapeHandler?: () => void;
|
|
279
|
+
retryCountdownTimer?: ReturnType<typeof setInterval>;
|
|
279
280
|
unsubscribe?: () => void;
|
|
280
281
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
281
282
|
optimisticUserMessageSignature: string | undefined = undefined;
|
package/src/modes/types.ts
CHANGED
|
@@ -109,6 +109,7 @@ export interface InteractiveModeContext {
|
|
|
109
109
|
retryLoader: Loader | undefined;
|
|
110
110
|
autoCompactionEscapeHandler?: () => void;
|
|
111
111
|
retryEscapeHandler?: () => void;
|
|
112
|
+
retryCountdownTimer?: ReturnType<typeof setInterval>;
|
|
112
113
|
unsubscribe?: () => void;
|
|
113
114
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
114
115
|
optimisticUserMessageSignature: string | undefined;
|
|
@@ -31,6 +31,19 @@ Explore just enough context, implement the smallest correct change, and leave co
|
|
|
31
31
|
5. Remove debug leftovers and report changed files plus evidence.
|
|
32
32
|
</execution_loop>
|
|
33
33
|
|
|
34
|
+
<ultragoal_red_team_mode>
|
|
35
|
+
This mode activates only when the assignment explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence. Otherwise, preserve ordinary Executor behavior.
|
|
36
|
+
|
|
37
|
+
When active:
|
|
38
|
+
- Start from the approved plan/spec/acceptance criteria, then user-facing contracts, then implementation code only as supporting evidence. Treat plan/code mismatches as blockers.
|
|
39
|
+
- Exercise the real user-facing invocation rather than inspecting internals alone: GUI/web uses browser automation plus screenshot or image verdict; CLI uses logs or terminal transcripts; API/package uses external consumer or black-box tests through the public interface; algorithm/math uses boundary, property, adversarial, and failure-mode cases.
|
|
40
|
+
- Try to break the work with adversarial cases, not just happy-path confirmations.
|
|
41
|
+
- Report the QA matrix with the final field names `executorQa.contractCoverage`, `executorQa.surfaceEvidence`, `executorQa.adversarialCases`, and `executorQa.artifactRefs`.
|
|
42
|
+
- Include artifact refs for every executed surface and adversarial case: transcript ids, log paths, screenshots, image verdicts, test outputs, or other durable evidence.
|
|
43
|
+
- Use `status: "not_applicable"` only for rows in `executorQa.contractCoverage` and `executorQa.surfaceEvidence`; each not-applicable row requires `contractRef` plus `reason`. `executorQa.adversarialCases` rows cannot be not-applicable.
|
|
44
|
+
- Report blockers for any missing plan/spec/acceptance source, contract ambiguity, plan/code mismatch, untestable surface, failed adversarial case, shallow evidence, or missing artifact ref.
|
|
45
|
+
</ultragoal_red_team_mode>
|
|
46
|
+
|
|
34
47
|
<success_criteria>
|
|
35
48
|
- Requested behavior is implemented in the assigned scope.
|
|
36
49
|
- Modified files match existing style and contracts.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
Lists, inspects, awaits, or cancels detached task subagents.
|
|
1
|
+
Lists, inspects, awaits, pauses, resumes, steers, or cancels detached task subagents.
|
|
2
2
|
|
|
3
3
|
Task launches return immediately. Use this tool when you need direct control over those running subagents. Prefer `subagent` for task subagents; generic `job` remains available for non-subagent jobs and compatibility fallback access.
|
|
4
4
|
|
|
5
5
|
# Operations
|
|
6
6
|
|
|
7
7
|
## `action: "list"`
|
|
8
|
-
Snapshot your visible detached subagents.
|
|
8
|
+
Snapshot your visible detached subagents, including `running`, `paused`, `queued`, and terminal subagents when retained.
|
|
9
9
|
|
|
10
10
|
## `action: "inspect"`
|
|
11
11
|
Inspect selected subagents by `ids`; omit `ids` to inspect current running subagents. Terminal subagents include final output when retained.
|
|
@@ -16,6 +16,36 @@ Wait for selected subagents by `ids`; omit `ids` to wait for current running sub
|
|
|
16
16
|
- Await timeout only bounds this tool call's wait; it does not stop the subagent and is not a failure reason.
|
|
17
17
|
- On timeout, inspect progress and keep doing independent work. Never cancel just because an await timed out; cancel only if the subagent has actually failed, gone off-track, or become unrecoverably wrong.
|
|
18
18
|
|
|
19
|
+
## `action: "pause"`
|
|
20
|
+
Request a graceful safe-boundary pause for selected subagents by `ids`.
|
|
21
|
+
- Non-running subagents are a no-op and return their current status snapshot.
|
|
22
|
+
- A paused subagent keeps its session context and can be resumed later.
|
|
23
|
+
|
|
24
|
+
## `action: "resume"`
|
|
25
|
+
Resume selected non-running subagents by `ids`.
|
|
26
|
+
- Optional `message` is delivered into the resumed run.
|
|
27
|
+
- Running subagents are a no-op and return their current status snapshot.
|
|
28
|
+
- Terminal subagents require `message` to start a follow-up resume run; without `message`, the tool returns the current snapshot with guidance.
|
|
29
|
+
- `paused` subagents resume from saved context; `queued` subagents are already waiting for capacity.
|
|
30
|
+
|
|
31
|
+
## `action: "steer"`
|
|
32
|
+
Send a non-empty `message` to selected subagents by `ids`.
|
|
33
|
+
- Running subagents receive the message through their live handle.
|
|
34
|
+
- Optional `pause: true` requests a safe-boundary pause after steering a running subagent.
|
|
35
|
+
- `pause` only matters while the target is running.
|
|
36
|
+
- Non-active subagents (`paused`, `queued`, or terminal) automatically resume with the message; `pause` is ignored for these targets.
|
|
37
|
+
|
|
19
38
|
## `action: "cancel"`
|
|
20
|
-
Stop selected
|
|
39
|
+
Stop selected subagents by `ids`, including running, paused, or queued subagents.
|
|
21
40
|
- Use only when the subagent has actually failed, gone off-track, or become unrecoverably wrong; an await timeout alone is never a cancellation reason.
|
|
41
|
+
- Cancellation keeps the subagent session file for possible later context recovery.
|
|
42
|
+
|
|
43
|
+
# Statuses
|
|
44
|
+
|
|
45
|
+
- `running` — currently executing.
|
|
46
|
+
- `paused` — stopped at a safe boundary with resumable context.
|
|
47
|
+
- `queued` — resume requested and waiting for execution capacity.
|
|
48
|
+
- `completed` — finished successfully.
|
|
49
|
+
- `failed` — finished with an error.
|
|
50
|
+
- `cancelled` — stopped by cancellation.
|
|
51
|
+
- `not_found` — no visible subagent matches the requested id.
|
package/src/sdk.ts
CHANGED
|
@@ -327,6 +327,8 @@ export interface CreateAgentSessionOptions {
|
|
|
327
327
|
forkContextSeed?: ForkContextSeed;
|
|
328
328
|
/** Optional provider state override. Fork-context children should omit this by default. */
|
|
329
329
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
330
|
+
/** Cooperative pause checkpoint passed through to Agent. */
|
|
331
|
+
shouldPause?: () => boolean;
|
|
330
332
|
}
|
|
331
333
|
|
|
332
334
|
/** Result from createAgentSession */
|
|
@@ -657,6 +659,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
|
|
|
657
659
|
reason: "auto_retry_start",
|
|
658
660
|
attempt: event.attempt,
|
|
659
661
|
maxAttempts: event.maxAttempts,
|
|
662
|
+
unbounded: event.unbounded,
|
|
660
663
|
delayMs: event.delayMs,
|
|
661
664
|
errorMessage: event.errorMessage,
|
|
662
665
|
},
|
|
@@ -1797,6 +1800,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1797
1800
|
requestMaxRetries: retrySettings.requestMaxRetries,
|
|
1798
1801
|
streamMaxRetries: retrySettings.streamMaxRetries,
|
|
1799
1802
|
kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
|
|
1803
|
+
shouldPause: options.shouldPause,
|
|
1800
1804
|
preferWebsockets: preferOpenAICodexWebsockets,
|
|
1801
1805
|
getToolContext: tc => toolContextStore.getContext(tc),
|
|
1802
1806
|
getApiKey: async provider => {
|
|
@@ -167,6 +167,7 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
|
167
167
|
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
168
168
|
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
169
169
|
import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
|
|
170
|
+
import { writeArtifact } from "../gjc-runtime/state-writer";
|
|
170
171
|
import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
|
|
171
172
|
import { GoalRuntime } from "../goals/runtime";
|
|
172
173
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
@@ -263,7 +264,14 @@ export type AgentSessionEvent =
|
|
|
263
264
|
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
|
264
265
|
skipped?: boolean;
|
|
265
266
|
}
|
|
266
|
-
| {
|
|
267
|
+
| {
|
|
268
|
+
type: "auto_retry_start";
|
|
269
|
+
attempt: number;
|
|
270
|
+
maxAttempts: number;
|
|
271
|
+
delayMs: number;
|
|
272
|
+
errorMessage: string;
|
|
273
|
+
unbounded?: boolean;
|
|
274
|
+
}
|
|
267
275
|
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
|
268
276
|
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
|
|
269
277
|
| { type: "retry_fallback_succeeded"; model: string; role: string }
|
|
@@ -282,6 +290,11 @@ export type AgentSessionEvent =
|
|
|
282
290
|
*/
|
|
283
291
|
const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
284
292
|
|
|
293
|
+
function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
|
|
294
|
+
const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
|
|
295
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
296
|
+
}
|
|
297
|
+
|
|
285
298
|
/** Listener function for agent session events */
|
|
286
299
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
287
300
|
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
@@ -852,6 +865,7 @@ export class AgentSession {
|
|
|
852
865
|
|
|
853
866
|
// Retry state
|
|
854
867
|
#retryAbortController: AbortController | undefined = undefined;
|
|
868
|
+
#retryNowRequested = false;
|
|
855
869
|
#retryAttempt = 0;
|
|
856
870
|
#retryPromise: Promise<void> | undefined = undefined;
|
|
857
871
|
#retryResolve: (() => void) | undefined = undefined;
|
|
@@ -1887,6 +1901,15 @@ export class AgentSession {
|
|
|
1887
1901
|
attempt: this.#retryAttempt,
|
|
1888
1902
|
});
|
|
1889
1903
|
this.#retryAttempt = 0;
|
|
1904
|
+
// Settle the retry gate here, colocated with the success event, rather
|
|
1905
|
+
// than relying on the generic #resolveRetry() at the end of the
|
|
1906
|
+
// agent_end branch. That tail resolver is bypassed by every early
|
|
1907
|
+
// return in agent_end (successful `yield`, handoff-abort skip-maintenance,
|
|
1908
|
+
// missing assistant message), so a retry that recovers on a yield turn
|
|
1909
|
+
// would otherwise leave #retryPromise unresolved — wedging
|
|
1910
|
+
// #waitForPostPromptRecovery and the session as permanently busy.
|
|
1911
|
+
// #resolveRetry() is idempotent, so the later tail call is a no-op.
|
|
1912
|
+
this.#resolveRetry();
|
|
1890
1913
|
}
|
|
1891
1914
|
}
|
|
1892
1915
|
|
|
@@ -2001,6 +2024,18 @@ export class AgentSession {
|
|
|
2001
2024
|
const didRetry = await this.#handleRetryableError(msg);
|
|
2002
2025
|
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
|
2003
2026
|
}
|
|
2027
|
+
if (this.#retryAttempt > 0) {
|
|
2028
|
+
// A prior retry ended on a non-retryable (terminal) message: emit
|
|
2029
|
+
// the terminal retry-end and reset so observers clear retry state.
|
|
2030
|
+
const attempt = this.#retryAttempt;
|
|
2031
|
+
this.#retryAttempt = 0;
|
|
2032
|
+
await this.#emitSessionEvent({
|
|
2033
|
+
type: "auto_retry_end",
|
|
2034
|
+
success: false,
|
|
2035
|
+
attempt,
|
|
2036
|
+
finalError: msg.errorMessage,
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2004
2039
|
this.#resolveRetry();
|
|
2005
2040
|
|
|
2006
2041
|
const compactionTask = this.#checkCompaction(msg);
|
|
@@ -2871,6 +2906,7 @@ export class AgentSession {
|
|
|
2871
2906
|
maxAttempts: event.maxAttempts,
|
|
2872
2907
|
delayMs: event.delayMs,
|
|
2873
2908
|
errorMessage: event.errorMessage,
|
|
2909
|
+
unbounded: event.unbounded,
|
|
2874
2910
|
});
|
|
2875
2911
|
} else if (event.type === "auto_retry_end") {
|
|
2876
2912
|
await this.#extensionRunner.emit({
|
|
@@ -3485,7 +3521,7 @@ export class AgentSession {
|
|
|
3485
3521
|
* prompts or tool execution can run.
|
|
3486
3522
|
*/
|
|
3487
3523
|
#wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
|
|
3488
|
-
if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
|
|
3524
|
+
if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
|
|
3489
3525
|
return new Proxy(tool, {
|
|
3490
3526
|
get: (target, prop) => {
|
|
3491
3527
|
if (prop !== "execute") return Reflect.get(target, prop, target);
|
|
@@ -5027,7 +5063,18 @@ export class AgentSession {
|
|
|
5027
5063
|
/**
|
|
5028
5064
|
* Abort current operation and wait for agent to become idle.
|
|
5029
5065
|
*/
|
|
5030
|
-
async abort(options?: {
|
|
5066
|
+
async abort(options?: {
|
|
5067
|
+
goalReason?: "interrupted" | "internal";
|
|
5068
|
+
timeoutMs?: number;
|
|
5069
|
+
cause?:
|
|
5070
|
+
| "user_interrupt"
|
|
5071
|
+
| "new_session"
|
|
5072
|
+
| "session_switch"
|
|
5073
|
+
| "compaction"
|
|
5074
|
+
| "handoff"
|
|
5075
|
+
| "tool_abort"
|
|
5076
|
+
| "internal";
|
|
5077
|
+
}): Promise<void> {
|
|
5031
5078
|
this.abortRetry();
|
|
5032
5079
|
this.#promptGeneration++;
|
|
5033
5080
|
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
@@ -5074,6 +5121,18 @@ export class AgentSession {
|
|
|
5074
5121
|
if (this.#toolChoiceQueue.hasInFlight) {
|
|
5075
5122
|
this.#toolChoiceQueue.reject("aborted");
|
|
5076
5123
|
}
|
|
5124
|
+
|
|
5125
|
+
// Steer-on-interrupt: after a genuine user interrupt, resume with any
|
|
5126
|
+
// queued steering instead of going idle. Lifecycle/teardown causes
|
|
5127
|
+
// (default "internal") suppress this; new-session/handoff additionally
|
|
5128
|
+
// clear the steering queue, and compaction resumes via its own path.
|
|
5129
|
+
if ((options?.cause ?? "internal") === "user_interrupt" && this.agent.hasQueuedSteering()) {
|
|
5130
|
+
this.#scheduleAgentContinue({
|
|
5131
|
+
delayMs: 1,
|
|
5132
|
+
generation: this.#promptGeneration,
|
|
5133
|
+
shouldContinue: () => this.agent.hasQueuedSteering(),
|
|
5134
|
+
});
|
|
5135
|
+
}
|
|
5077
5136
|
}
|
|
5078
5137
|
|
|
5079
5138
|
/**
|
|
@@ -5931,7 +5990,14 @@ export class AgentSession {
|
|
|
5931
5990
|
if (artifactsDir) {
|
|
5932
5991
|
const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
|
|
5933
5992
|
try {
|
|
5934
|
-
|
|
5993
|
+
if (isUnderProjectGjc(this.sessionManager.getCwd(), handoffFilePath)) {
|
|
5994
|
+
await writeArtifact(handoffFilePath, `${handoffText}\n`, {
|
|
5995
|
+
cwd: this.sessionManager.getCwd(),
|
|
5996
|
+
audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
|
|
5997
|
+
});
|
|
5998
|
+
} else {
|
|
5999
|
+
await Bun.write(handoffFilePath, `${handoffText}\n`);
|
|
6000
|
+
}
|
|
5935
6001
|
savedPath = handoffFilePath;
|
|
5936
6002
|
} catch (error) {
|
|
5937
6003
|
logger.warn("Failed to save handoff document to disk", {
|
|
@@ -7121,19 +7187,14 @@ export class AgentSession {
|
|
|
7121
7187
|
// =========================================================================
|
|
7122
7188
|
|
|
7123
7189
|
/**
|
|
7124
|
-
*
|
|
7125
|
-
*
|
|
7126
|
-
*
|
|
7190
|
+
* Whether an error should be retried. Uses the ordered classifier:
|
|
7191
|
+
* context-overflow routes to compaction; clearly-terminal coded errors
|
|
7192
|
+
* (auth/400/not-found) surface immediately; usage-limit, transient, and
|
|
7193
|
+
* unknown/no-code errors are retryable.
|
|
7127
7194
|
*/
|
|
7128
7195
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
// Context overflow is handled by compaction, not retry
|
|
7132
|
-
const contextWindow = this.model?.contextWindow ?? 0;
|
|
7133
|
-
if (isContextOverflow(message, contextWindow)) return false;
|
|
7134
|
-
|
|
7135
|
-
const err = message.errorMessage;
|
|
7136
|
-
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
|
7196
|
+
const classification = this.#classifyErrorForRetry(message);
|
|
7197
|
+
return classification === "usage_limit" || classification === "transient" || classification === "unknown";
|
|
7137
7198
|
}
|
|
7138
7199
|
|
|
7139
7200
|
#isTransientErrorMessage(errorMessage: string): boolean {
|
|
@@ -7159,6 +7220,63 @@ export class AgentSession {
|
|
|
7159
7220
|
);
|
|
7160
7221
|
}
|
|
7161
7222
|
|
|
7223
|
+
#isTerminalErrorMessage(errorMessage: string): boolean {
|
|
7224
|
+
// Errors that will never succeed on retry (auth/permission, malformed
|
|
7225
|
+
// request, unknown/unsupported model). These surface immediately rather
|
|
7226
|
+
// than retry forever.
|
|
7227
|
+
return /unauthorized|forbidden|authentication_error|permission_error|permission denied|invalid api key|invalid_request_error|invalid request|bad request|bad_request|validation_error|unprocessable|payload too large|payment required|insufficient_quota|insufficient credits|missing required (parameter|field)|invalid schema|invalid tool_choice|unsupported (parameter|value|model)|model_not_found|no such model|unknown model|does not (exist|support)|request was aborted|request aborted|the user aborted/i.test(
|
|
7228
|
+
errorMessage,
|
|
7229
|
+
);
|
|
7230
|
+
}
|
|
7231
|
+
|
|
7232
|
+
#extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
|
|
7233
|
+
// Parse only explicit HTTP/status wording. Do not treat generic
|
|
7234
|
+
// `error: 400` as an HTTP status because rate-limit copy can say
|
|
7235
|
+
// "rate limit error: 400 requests per minute".
|
|
7236
|
+
const match = /\b(?:http(?:\s+status)?|status(?:[\s_-]+code)?)(?:\s+|[:=]\s*)(\d{3})\b/i.exec(errorMessage);
|
|
7237
|
+
if (!match) return undefined;
|
|
7238
|
+
const status = Number(match[1]);
|
|
7239
|
+
return Number.isFinite(status) && status >= 100 && status <= 599 ? status : undefined;
|
|
7240
|
+
}
|
|
7241
|
+
|
|
7242
|
+
/**
|
|
7243
|
+
* Ordered retry classification: overflow (compaction) -> terminal (surface)
|
|
7244
|
+
* -> usage_limit (rotation) -> transient (retry) -> unknown (retry).
|
|
7245
|
+
*/
|
|
7246
|
+
#classifyErrorForRetry(
|
|
7247
|
+
message: AssistantMessage,
|
|
7248
|
+
): "none" | "overflow" | "terminal" | "usage_limit" | "transient" | "unknown" {
|
|
7249
|
+
if (message.stopReason !== "error" || !message.errorMessage) return "none";
|
|
7250
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
7251
|
+
if (isContextOverflow(message, contextWindow)) return "overflow";
|
|
7252
|
+
const err = message.errorMessage;
|
|
7253
|
+
// Stream-envelope errors are only transient in the pre-message_start
|
|
7254
|
+
// variant; any other envelope failure is structural and must surface.
|
|
7255
|
+
if (/anthropic stream envelope error:/i.test(err)) {
|
|
7256
|
+
return this.#isTransientEnvelopeErrorMessage(err) ? "transient" : "terminal";
|
|
7257
|
+
}
|
|
7258
|
+
const explicitStatus = this.#extractExplicitHttpStatusFromErrorMessage(err);
|
|
7259
|
+
const structuredStatus = message.errorStatus;
|
|
7260
|
+
const terminalStatus = explicitStatus ?? structuredStatus;
|
|
7261
|
+
const isTerminalHttp4xx =
|
|
7262
|
+
terminalStatus !== undefined &&
|
|
7263
|
+
terminalStatus >= 400 &&
|
|
7264
|
+
terminalStatus < 500 &&
|
|
7265
|
+
terminalStatus !== 408 &&
|
|
7266
|
+
terminalStatus !== 425 &&
|
|
7267
|
+
terminalStatus !== 429;
|
|
7268
|
+
if (this.#isTerminalErrorMessage(err)) return "terminal";
|
|
7269
|
+
if (isUsageLimitError(err)) return "usage_limit";
|
|
7270
|
+
// Explicit HTTP/status wording is authoritative. Structured provider status
|
|
7271
|
+
// is also authoritative except for rate-limit copy where providers may have
|
|
7272
|
+
// parsed an incidental quota number such as "400 requests per minute".
|
|
7273
|
+
if (isTerminalHttp4xx && (explicitStatus !== undefined || !/rate.?limit|too many requests/i.test(err))) {
|
|
7274
|
+
return "terminal";
|
|
7275
|
+
}
|
|
7276
|
+
if (this.#isTransientErrorMessage(err)) return "transient";
|
|
7277
|
+
return "unknown";
|
|
7278
|
+
}
|
|
7279
|
+
|
|
7162
7280
|
#getRetryFallbackChains(): RetryFallbackChains {
|
|
7163
7281
|
const configuredChains = this.settings.get("retry.fallbackChains");
|
|
7164
7282
|
if (!configuredChains || typeof configuredChains !== "object") return {};
|
|
@@ -7428,6 +7546,8 @@ export class AgentSession {
|
|
|
7428
7546
|
async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
|
|
7429
7547
|
const retrySettings = this.settings.getGroup("retry");
|
|
7430
7548
|
if (!retrySettings.enabled) return false;
|
|
7549
|
+
const retryClassification = this.#classifyErrorForRetry(message);
|
|
7550
|
+
const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
|
|
7431
7551
|
|
|
7432
7552
|
const generation = this.#promptGeneration;
|
|
7433
7553
|
this.#retryAttempt++;
|
|
@@ -7440,7 +7560,7 @@ export class AgentSession {
|
|
|
7440
7560
|
this.#retryResolve = resolve;
|
|
7441
7561
|
}
|
|
7442
7562
|
|
|
7443
|
-
if (this.#retryAttempt > retrySettings.maxRetries) {
|
|
7563
|
+
if (!unboundedClass && this.#retryAttempt > retrySettings.maxRetries) {
|
|
7444
7564
|
// Max retries exceeded, emit final failure and reset
|
|
7445
7565
|
await this.#emitSessionEvent({
|
|
7446
7566
|
type: "auto_retry_end",
|
|
@@ -7497,7 +7617,16 @@ export class AgentSession {
|
|
|
7497
7617
|
// assistant error message is preserved in agent state so the caller
|
|
7498
7618
|
// can act on it.
|
|
7499
7619
|
const maxDelayMs = retrySettings.maxDelayMs;
|
|
7500
|
-
if (
|
|
7620
|
+
if (unboundedClass && !switchedCredential && !switchedModel) {
|
|
7621
|
+
// Retry forever: honor a provider-supplied wait, otherwise cap the
|
|
7622
|
+
// exponential backoff at the ceiling instead of giving up.
|
|
7623
|
+
if (parsedRetryAfterMs !== undefined) {
|
|
7624
|
+
delayMs = Math.max(delayMs, parsedRetryAfterMs);
|
|
7625
|
+
} else if (maxDelayMs > 0) {
|
|
7626
|
+
delayMs = Math.min(delayMs, maxDelayMs);
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
if (!unboundedClass && maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
|
|
7501
7630
|
const attempt = this.#retryAttempt;
|
|
7502
7631
|
this.#retryAttempt = 0;
|
|
7503
7632
|
await this.#emitSessionEvent({
|
|
@@ -7510,12 +7639,22 @@ export class AgentSession {
|
|
|
7510
7639
|
return false;
|
|
7511
7640
|
}
|
|
7512
7641
|
|
|
7642
|
+
// Create and install the backoff abort controller BEFORE emitting
|
|
7643
|
+
// auto_retry_start, so a synchronous retryNow()/abortRetry() invoked from
|
|
7644
|
+
// an event subscriber (e.g. the TUI Esc handler) is not lost in the gap
|
|
7645
|
+
// between the event and the controller assignment.
|
|
7646
|
+
const retryAbortController = new AbortController();
|
|
7647
|
+
this.#retryAbortController?.abort();
|
|
7648
|
+
this.#retryAbortController = retryAbortController;
|
|
7649
|
+
this.#retryNowRequested = false;
|
|
7650
|
+
|
|
7513
7651
|
await this.#emitSessionEvent({
|
|
7514
7652
|
type: "auto_retry_start",
|
|
7515
7653
|
attempt: this.#retryAttempt,
|
|
7516
7654
|
maxAttempts: retrySettings.maxRetries,
|
|
7517
7655
|
delayMs,
|
|
7518
7656
|
errorMessage,
|
|
7657
|
+
unbounded: unboundedClass,
|
|
7519
7658
|
});
|
|
7520
7659
|
|
|
7521
7660
|
// Remove error message from agent state (keep in session for history)
|
|
@@ -7525,34 +7664,49 @@ export class AgentSession {
|
|
|
7525
7664
|
}
|
|
7526
7665
|
|
|
7527
7666
|
// Wait with exponential backoff (abortable).
|
|
7528
|
-
const retryAbortController = new AbortController();
|
|
7529
|
-
this.#retryAbortController?.abort();
|
|
7530
|
-
this.#retryAbortController = retryAbortController;
|
|
7531
7667
|
try {
|
|
7532
7668
|
await scheduler.wait(delayMs, { signal: retryAbortController.signal });
|
|
7533
7669
|
} catch {
|
|
7534
7670
|
if (this.#retryAbortController !== retryAbortController) {
|
|
7535
7671
|
return false;
|
|
7536
7672
|
}
|
|
7537
|
-
// Aborted during sleep - emit end event so UI can clean up
|
|
7538
|
-
const attempt = this.#retryAttempt;
|
|
7539
|
-
this.#retryAttempt = 0;
|
|
7540
7673
|
this.#retryAbortController = undefined;
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7674
|
+
if (this.#retryNowRequested) {
|
|
7675
|
+
// Retry-now: skip the remaining backoff and fall through to
|
|
7676
|
+
// re-attempt immediately (keeps the retry session alive).
|
|
7677
|
+
this.#retryNowRequested = false;
|
|
7678
|
+
} else {
|
|
7679
|
+
// Aborted during sleep (cancel) - emit end event so UI can clean up
|
|
7680
|
+
const attempt = this.#retryAttempt;
|
|
7681
|
+
this.#retryAttempt = 0;
|
|
7682
|
+
await this.#emitSessionEvent({
|
|
7683
|
+
type: "auto_retry_end",
|
|
7684
|
+
success: false,
|
|
7685
|
+
attempt,
|
|
7686
|
+
finalError: "Retry cancelled",
|
|
7687
|
+
});
|
|
7688
|
+
this.#resolveRetry();
|
|
7689
|
+
return false;
|
|
7690
|
+
}
|
|
7549
7691
|
}
|
|
7550
7692
|
if (this.#retryAbortController === retryAbortController) {
|
|
7551
7693
|
this.#retryAbortController = undefined;
|
|
7552
7694
|
}
|
|
7553
7695
|
|
|
7554
7696
|
// Retry via continue() outside the agent_end event callback chain.
|
|
7555
|
-
|
|
7697
|
+
// If the scheduled continue cannot run — it throws (e.g. AgentBusyError from a
|
|
7698
|
+
// concurrent turn, or "Cannot continue ...") or is skipped because a newer
|
|
7699
|
+
// generation took over — the agent_end that normally resolves #retryPromise
|
|
7700
|
+
// never arrives. Finalize the retry in that case so #waitForPostPromptRecovery
|
|
7701
|
+
// (and the in-flight prompt holding it open) cannot wedge the session as
|
|
7702
|
+
// permanently busy, which would turn every later prompt() into a
|
|
7703
|
+
// non-recoverable AgentBusyError loop.
|
|
7704
|
+
this.#scheduleAgentContinue({
|
|
7705
|
+
delayMs: 1,
|
|
7706
|
+
generation,
|
|
7707
|
+
onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
|
|
7708
|
+
onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
|
|
7709
|
+
});
|
|
7556
7710
|
|
|
7557
7711
|
return true;
|
|
7558
7712
|
}
|
|
@@ -7561,8 +7715,41 @@ export class AgentSession {
|
|
|
7561
7715
|
* Cancel in-progress retry.
|
|
7562
7716
|
*/
|
|
7563
7717
|
abortRetry(): void {
|
|
7718
|
+
this.#retryNowRequested = false;
|
|
7564
7719
|
this.#retryAbortController?.abort();
|
|
7565
|
-
// Note:
|
|
7720
|
+
// Note: #retryAttempt is reset in the catch block of #handleRetryableError
|
|
7721
|
+
this.#resolveRetry();
|
|
7722
|
+
}
|
|
7723
|
+
|
|
7724
|
+
/**
|
|
7725
|
+
* Skip the current retry backoff and re-attempt immediately. Distinct from
|
|
7726
|
+
* abortRetry(), which cancels the retry and returns to idle. No-op when no
|
|
7727
|
+
* retry backoff is active.
|
|
7728
|
+
*/
|
|
7729
|
+
retryNow(): void {
|
|
7730
|
+
if (!this.#retryAbortController) return;
|
|
7731
|
+
this.#retryNowRequested = true;
|
|
7732
|
+
this.#retryAbortController.abort();
|
|
7733
|
+
}
|
|
7734
|
+
|
|
7735
|
+
/**
|
|
7736
|
+
* Finalize a pending auto-retry that can no longer reach a resolving agent_end
|
|
7737
|
+
* (the scheduled continue threw or was superseded). Without this, #retryPromise
|
|
7738
|
+
* stays unresolved, #waitForPostPromptRecovery never returns, the owning
|
|
7739
|
+
* prompt's in-flight count is never released, and the session reports
|
|
7740
|
+
* `isStreaming === true` forever — turning every later prompt() into a
|
|
7741
|
+
* non-recoverable AgentBusyError. No-op once the retry has already settled.
|
|
7742
|
+
*/
|
|
7743
|
+
#failRetryRecovery(reason: string): void {
|
|
7744
|
+
if (!this.#retryPromise) return;
|
|
7745
|
+
const attempt = this.#retryAttempt;
|
|
7746
|
+
this.#retryAttempt = 0;
|
|
7747
|
+
void this.#emitSessionEvent({
|
|
7748
|
+
type: "auto_retry_end",
|
|
7749
|
+
success: false,
|
|
7750
|
+
attempt,
|
|
7751
|
+
finalError: reason,
|
|
7752
|
+
});
|
|
7566
7753
|
this.#resolveRetry();
|
|
7567
7754
|
}
|
|
7568
7755
|
|
|
@@ -8279,6 +8466,8 @@ export class AgentSession {
|
|
|
8279
8466
|
const previousFallbackSelectedMCPToolNames = previousSessionFile
|
|
8280
8467
|
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
|
|
8281
8468
|
: undefined;
|
|
8469
|
+
const previousAgentSteeringQueue = this.agent.snapshotSteering();
|
|
8470
|
+
const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
8282
8471
|
|
|
8283
8472
|
this.#steeringMessages = [];
|
|
8284
8473
|
this.#followUpMessages = [];
|
|
@@ -8297,6 +8486,12 @@ export class AgentSession {
|
|
|
8297
8486
|
const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
|
|
8298
8487
|
await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
|
|
8299
8488
|
|
|
8489
|
+
// The target session is loaded and MCP selections are restored: the
|
|
8490
|
+
// switch is committed far enough to discard pre-switch delivery queues.
|
|
8491
|
+
// Clear before session_switch hooks, so messages enqueued by hooks belong
|
|
8492
|
+
// to the new session and remain deliverable.
|
|
8493
|
+
this.agent.clearAllQueues();
|
|
8494
|
+
|
|
8300
8495
|
// Emit session_switch event to hooks
|
|
8301
8496
|
if (this.#extensionRunner) {
|
|
8302
8497
|
await this.#extensionRunner.emit({
|
|
@@ -8391,6 +8586,9 @@ export class AgentSession {
|
|
|
8391
8586
|
this.#followUpMessages = previousFollowUpMessages;
|
|
8392
8587
|
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
|
8393
8588
|
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
8589
|
+
this.agent.clearAllQueues();
|
|
8590
|
+
this.agent.restoreSteering(previousAgentSteeringQueue);
|
|
8591
|
+
this.agent.restoreFollowUp(previousAgentFollowUpQueue);
|
|
8394
8592
|
if (previousModel) {
|
|
8395
8593
|
this.agent.setModel(previousModel);
|
|
8396
8594
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
Snowflake,
|
|
28
28
|
toError,
|
|
29
29
|
} from "@gajae-code/utils";
|
|
30
|
+
import { writeTextAtomic } from "../gjc-runtime/state-writer";
|
|
30
31
|
import { ArtifactManager } from "./artifacts";
|
|
31
32
|
import {
|
|
32
33
|
type BlobPutResult,
|
|
@@ -56,6 +57,10 @@ import type { SessionStorage, SessionStorageWriter } from "./session-storage";
|
|
|
56
57
|
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
57
58
|
|
|
58
59
|
export const CURRENT_SESSION_VERSION = 3;
|
|
60
|
+
function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
|
|
61
|
+
const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
|
|
62
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
63
|
+
}
|
|
59
64
|
|
|
60
65
|
export interface SessionHeader {
|
|
61
66
|
type: "session";
|
|
@@ -384,6 +389,7 @@ const migratedSessionRoots = new Set<string>();
|
|
|
384
389
|
* Best effort: callers decide whether migration failures should surface.
|
|
385
390
|
*/
|
|
386
391
|
function migrateSessionDirPath(oldPath: string, newPath: string): void {
|
|
392
|
+
// Session-dir lifecycle migration: moves/removes whole directories, not file content writes.
|
|
387
393
|
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
388
394
|
if (existing?.isDirectory()) {
|
|
389
395
|
for (const file of fs.readdirSync(oldPath)) {
|
|
@@ -752,7 +758,13 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
|
|
752
758
|
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
|
|
753
759
|
const content = `${cwd}\n${sessionFile}\n`;
|
|
754
760
|
// Best-effort — don't break session creation if breadcrumb fails
|
|
755
|
-
|
|
761
|
+
const write = isUnderProjectGjc(cwd, breadcrumbFile)
|
|
762
|
+
? writeTextAtomic(breadcrumbFile, content, {
|
|
763
|
+
cwd,
|
|
764
|
+
audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
|
|
765
|
+
})
|
|
766
|
+
: Bun.write(breadcrumbFile, content);
|
|
767
|
+
write.catch(() => {});
|
|
756
768
|
}
|
|
757
769
|
|
|
758
770
|
/**
|