@gajae-code/coding-agent 0.12.2 → 0.12.5

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/dist/types/async/job-manager.d.ts +9 -1
  3. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +5 -1
  4. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -2
  5. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -2
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -3
  7. package/dist/types/session/internal/managed-session-storage.d.ts +3 -22
  8. package/dist/types/session/session-manager.d.ts +1 -1
  9. package/dist/types/task/executor.d.ts +1 -1
  10. package/dist/types/task/index.d.ts +2 -1
  11. package/dist/types/task/receipt.d.ts +1 -0
  12. package/dist/types/task/types.d.ts +16 -0
  13. package/dist/types/task/worktree.d.ts +13 -6
  14. package/dist/types/tools/subagent.d.ts +2 -0
  15. package/dist/types/utils/git.d.ts +1 -0
  16. package/package.json +8 -8
  17. package/src/async/job-manager.ts +13 -5
  18. package/src/config/provider-ranking.ts +1 -0
  19. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -5
  20. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +2 -1
  21. package/src/gjc-runtime/ultragoal-evidence.ts +160 -31
  22. package/src/gjc-runtime/ultragoal-runtime.ts +231 -19
  23. package/src/internal-urls/docs-index.generated.ts +2 -2
  24. package/src/sdk/broker/lifecycle.ts +13 -18
  25. package/src/sdk/bus/chat-daemon-control.ts +6 -4
  26. package/src/sdk/bus/telegram-daemon-contract.ts +5 -3
  27. package/src/sdk/session.ts +1 -0
  28. package/src/session/internal/managed-session-scope.ts +107 -158
  29. package/src/session/internal/managed-session-storage.ts +57 -445
  30. package/src/session/session-manager.ts +12 -7
  31. package/src/session/session-storage.ts +44 -29
  32. package/src/task/executor.ts +5 -2
  33. package/src/task/index.ts +232 -64
  34. package/src/task/receipt.ts +22 -2
  35. package/src/task/render.ts +12 -1
  36. package/src/task/types.ts +18 -0
  37. package/src/task/worktree.ts +122 -43
  38. package/src/tools/subagent-render.ts +5 -2
  39. package/src/tools/subagent.ts +8 -0
  40. package/src/utils/git.ts +2 -0
@@ -39,6 +39,7 @@ export interface TaskResultReceipt {
39
39
  cost?: number;
40
40
  usageCostBreakdownComplete?: true;
41
41
  branchName?: string;
42
+ persistence?: SingleResult["persistence"];
42
43
  retryFailure?: { attempt: number; errorSummary: string };
43
44
  setupFailure?: { summary: string };
44
45
  errorSummary?: string;
@@ -128,6 +129,18 @@ function buildSafeSynopsis(raw: SingleResult, outputRef: TaskResultReceipt["outp
128
129
  if (raw.retryFailure) {
129
130
  return `Task ${status}; retry stopped after attempt ${raw.retryFailure.attempt}.`;
130
131
  }
132
+ if (raw.persistence?.outcome === "recovery_available") {
133
+ const recovery = raw.persistence.recoveryRef
134
+ ? ` Recovery patch: ${raw.persistence.recoveryRef.uri} (${raw.persistence.recoveryRef.sizeBytes} bytes).`
135
+ : "";
136
+ return `Task ${status}; changes were not persisted to the owner worktree.${recovery}`;
137
+ }
138
+ if (raw.persistence?.outcome === "applied") {
139
+ return `Task ${status}; changes persisted to the owner worktree.`;
140
+ }
141
+ if (raw.persistence?.outcome === "no_changes") {
142
+ return `Task ${status}; no changes to persist.`;
143
+ }
131
144
  if (raw.abortReason) {
132
145
  return `Task ${status}; abort reason recorded.`;
133
146
  }
@@ -143,7 +156,7 @@ function buildSafeSynopsis(raw: SingleResult, outputRef: TaskResultReceipt["outp
143
156
  function getStatus(raw: SingleResult): TaskResultReceipt["status"] {
144
157
  if (raw.paused) return "paused";
145
158
  if (raw.aborted) return "aborted";
146
- if (raw.exitCode === 0 && raw.error) return "merge_failed";
159
+ if (raw.exitCode === 0 && (raw.error || raw.persistence?.outcome === "recovery_available")) return "merge_failed";
147
160
  if (raw.exitCode !== 0 || raw.error) return "failed";
148
161
  return "completed";
149
162
  }
@@ -275,11 +288,18 @@ export function buildTaskReceipt(raw: SingleResult): TaskResultReceipt {
275
288
  usageCostBreakdownComplete:
276
289
  raw.usageCostBreakdownComplete === true && hasCompleteUsageCostBreakdown(raw.usage) ? true : undefined,
277
290
  branchName: raw.branchName,
291
+ persistence: raw.persistence,
278
292
  fastMode: raw.fastMode,
279
293
  retryFailure: raw.retryFailure
280
294
  ? { attempt: raw.retryFailure.attempt, errorSummary: "Retry failure recorded." }
281
295
  : undefined,
282
- errorSummary: raw.setupFailure?.summary ?? (raw.error ? "Error recorded." : undefined),
296
+ errorSummary:
297
+ raw.setupFailure?.summary ??
298
+ (raw.error
299
+ ? "Error recorded."
300
+ : raw.persistence?.outcome === "recovery_available"
301
+ ? "Changes were not persisted to the owner worktree."
302
+ : undefined),
283
303
  setupFailure: raw.setupFailure ? { summary: raw.setupFailure.summary } : undefined,
284
304
  abortSummary: raw.abortReason ? "Abort reason recorded." : undefined,
285
305
  preview,
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "@gajae-code/tui";
9
9
  import { Text } from "@gajae-code/tui";
10
- import { formatNumber } from "@gajae-code/utils";
10
+ import { formatNumber, sanitizeText } from "@gajae-code/utils";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import {
@@ -917,6 +917,17 @@ function renderAgentResult(result: TaskResultReceipt, isLast: boolean, expanded:
917
917
  lines.push(`${continuePrefix}${theme.fg("warning", "low ROI: produced no material contribution")}`);
918
918
  }
919
919
 
920
+ if (result.persistence?.recoveryRef) {
921
+ const label = result.persistence.ownerWorktreeApplied ? "Recovery patch" : "Unapplied recovery patch";
922
+ const recoveryUri = truncateToWidth(replaceTabs(sanitizeText(result.persistence.recoveryRef.uri)), 80);
923
+ lines.push(
924
+ `${continuePrefix}${theme.fg(
925
+ result.persistence.ownerWorktreeApplied ? "dim" : "warning",
926
+ `${label}: ${recoveryUri} (${formatBytes(result.persistence.recoveryRef.sizeBytes)})`,
927
+ )}`,
928
+ );
929
+ }
930
+
920
931
  if (result.outputRef) {
921
932
  lines.push(
922
933
  `${continuePrefix}${theme.fg(
package/src/task/types.ts CHANGED
@@ -425,6 +425,20 @@ export function createSetupFailureSummary(error: unknown): SetupFailureSummary {
425
425
  };
426
426
  }
427
427
 
428
+ export interface TaskRecoveryArtifactRef {
429
+ uri: string;
430
+ sizeBytes: number;
431
+ sha256: string;
432
+ /** Recovery artifacts remain readable for the parent session lifetime. */
433
+ durability: "session";
434
+ }
435
+
436
+ export interface TaskPersistenceResult {
437
+ outcome: "applied" | "no_changes" | "recovery_available";
438
+ ownerWorktreeApplied: boolean;
439
+ recoveryRef?: TaskRecoveryArtifactRef;
440
+ }
441
+
428
442
  /** Result from a single agent execution */
429
443
  export interface SingleResult {
430
444
  index: number;
@@ -470,6 +484,10 @@ export interface SingleResult {
470
484
  nestedPatches?: NestedRepoPatch[];
471
485
  /** Whether isolated execution produced a non-empty root or nested patch. */
472
486
  producedChanges?: boolean;
487
+ /** Receipt-safe owner-worktree persistence result for isolated execution. */
488
+ persistence?: TaskPersistenceResult;
489
+ /** Identity-bound patch artifact captured before isolation cleanup. */
490
+ recoveryRef?: TaskRecoveryArtifactRef;
473
491
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
474
492
  extractedToolData?: Record<string, unknown[]>;
475
493
  /** Full wrapper-owned review evidence, kept separate from caller completion data. */
@@ -83,13 +83,19 @@ async function captureUntrackedPatch(repoRoot: string, untracked: readonly strin
83
83
  if (untracked.length === 0) return "";
84
84
  const nullPath = getGitNoIndexNullPath();
85
85
  const untrackedDiffs = await Promise.all(
86
- untracked.map(entry =>
87
- git.diff(repoRoot, {
86
+ untracked.map(async entry => {
87
+ const absolutePath = path.join(repoRoot, entry);
88
+ const stat = await fs.lstat(absolutePath);
89
+ if (stat.isFile()) {
90
+ const handle = await fs.open(absolutePath, "r");
91
+ await handle.close();
92
+ }
93
+ return await git.diff(repoRoot, {
88
94
  allowFailure: true,
89
95
  binary: true,
90
96
  noIndex: { left: nullPath, right: entry },
91
- }),
92
- ),
97
+ });
98
+ }),
93
99
  );
94
100
  return untrackedDiffs.filter(diff => diff.trim()).join("\n");
95
101
  }
@@ -135,26 +141,40 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
135
141
  return { root, nested };
136
142
  }
137
143
 
138
- async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
144
+ async function captureRepoCurrentTree(repoDir: string): Promise<string> {
139
145
  const currentHead = (await git.head.sha(repoDir)) ?? "";
140
146
  const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
141
147
  const currentUnstaged = await git.diff(repoDir, { binary: true });
142
148
  const currentUntracked = await git.ls.untracked(repoDir);
143
149
  const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
150
+ return await writeSyntheticTree(repoDir, currentHead, [currentStaged, currentUnstaged, currentUntrackedPatch]);
151
+ }
144
152
 
153
+ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
145
154
  const baselineTree = await writeSyntheticTree(repoDir, rb.headCommit, [rb.staged, rb.unstaged, rb.untrackedPatch]);
146
- const currentTree = await writeSyntheticTree(repoDir, currentHead, [
147
- currentStaged,
148
- currentUnstaged,
149
- currentUntrackedPatch,
150
- ]);
155
+ const currentTree = await captureRepoCurrentTree(repoDir);
151
156
 
152
157
  return git.diff.tree(repoDir, baselineTree, currentTree, {
153
- allowFailure: true,
154
158
  binary: true,
155
159
  });
156
160
  }
157
161
 
162
+ /** Confirm that applying patches to the captured owner baseline exactly matches the current owner worktree. */
163
+ export async function verifyRootPatchesApplied(
164
+ repoRoot: string,
165
+ baseline: WorktreeBaseline,
166
+ patches: readonly string[],
167
+ ): Promise<boolean> {
168
+ const baselineTree = await writeSyntheticTree(repoRoot, baseline.root.headCommit, [
169
+ baseline.root.staged,
170
+ baseline.root.unstaged,
171
+ baseline.root.untrackedPatch,
172
+ ]);
173
+ const expectedTree = await writeSyntheticTree(repoRoot, baselineTree, patches);
174
+ const currentTree = await captureRepoCurrentTree(repoRoot);
175
+ return expectedTree === currentTree;
176
+ }
177
+
158
178
  export interface NestedRepoPatch {
159
179
  relativePath: string;
160
180
  patch: string;
@@ -163,64 +183,123 @@ export interface NestedRepoPatch {
163
183
  export interface DeltaPatchResult {
164
184
  rootPatch: string;
165
185
  nestedPatches: NestedRepoPatch[];
186
+ captureErrors?: string[];
187
+ }
188
+
189
+ export interface RecoveryPatchBundle {
190
+ version: 1;
191
+ rootPatch: string;
192
+ nestedPatches: NestedRepoPatch[];
193
+ captureErrors?: string[];
194
+ }
195
+
196
+ function captureErrorCode(error: unknown): string {
197
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
198
+ return typeof code === "string" && code.length > 0 ? code : "unknown_error";
199
+ }
200
+
201
+ export function serializeRecoveryPatchBundle(delta: DeltaPatchResult): string {
202
+ if (delta.nestedPatches.length === 0 && !delta.captureErrors?.length) return delta.rootPatch;
203
+ return `${JSON.stringify({
204
+ version: 1,
205
+ rootPatch: delta.rootPatch,
206
+ nestedPatches: delta.nestedPatches,
207
+ ...(delta.captureErrors?.length ? { captureErrors: delta.captureErrors } : {}),
208
+ } satisfies RecoveryPatchBundle)}\n`;
209
+ }
210
+
211
+ export async function verifyNestedPatchesApplied(
212
+ repoRoot: string,
213
+ baseline: WorktreeBaseline,
214
+ patches: readonly NestedRepoPatch[],
215
+ ): Promise<boolean> {
216
+ const grouped = new Map<string, string[]>();
217
+ for (const patch of patches) {
218
+ const current = grouped.get(patch.relativePath) ?? [];
219
+ current.push(patch.patch);
220
+ grouped.set(patch.relativePath, current);
221
+ }
222
+ for (const [relativePath, repoPatches] of grouped) {
223
+ const nestedBaseline = baseline.nested.find(entry => entry.relativePath === relativePath)?.baseline;
224
+ if (!nestedBaseline) return false;
225
+ const nestedDir = path.join(repoRoot, relativePath);
226
+ const baselineTree = await writeSyntheticTree(nestedDir, nestedBaseline.headCommit, [
227
+ nestedBaseline.staged,
228
+ nestedBaseline.unstaged,
229
+ nestedBaseline.untrackedPatch,
230
+ ]);
231
+ const expectedTree = await writeSyntheticTree(nestedDir, baselineTree, repoPatches);
232
+ if ((await captureRepoCurrentTree(nestedDir)) !== expectedTree) return false;
233
+ }
234
+ return true;
166
235
  }
167
236
 
168
237
  export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
169
238
  const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
170
239
  const nestedPatches: NestedRepoPatch[] = [];
240
+ const captureErrors: string[] = [];
171
241
 
172
242
  for (const { relativePath, baseline: nb } of baseline.nested) {
173
243
  const nestedDir = path.join(isolationDir, relativePath);
174
244
  try {
175
245
  await fs.access(path.join(nestedDir, ".git"));
176
- } catch {
177
- continue;
246
+ const patch = await captureRepoDeltaPatch(nestedDir, nb);
247
+ if (patch.trim()) nestedPatches.push({ relativePath, patch });
248
+ } catch (error) {
249
+ captureErrors.push(`Nested repository capture failed (${relativePath}): ${captureErrorCode(error)}`);
178
250
  }
179
- const patch = await captureRepoDeltaPatch(nestedDir, nb);
180
- if (patch.trim()) nestedPatches.push({ relativePath, patch });
181
251
  }
182
252
 
183
- return { rootPatch, nestedPatches };
253
+ return { rootPatch, nestedPatches, ...(captureErrors.length ? { captureErrors } : {}) };
184
254
  }
185
255
 
186
- /**
187
- * Apply nested repo patches directly to their working directories after parent merge.
188
- * @param commitMessage Optional async function to generate a commit message from the combined diff.
189
- * If omitted or returns null, falls back to a generic message.
190
- */
191
- export async function applyNestedPatches(
192
- repoRoot: string,
193
- patches: NestedRepoPatch[],
194
- commitMessage?: (diff: string) => Promise<string | null>,
195
- ): Promise<void> {
196
- // Group patches by target repo to apply all at once and commit
256
+ /** Preflight every nested delta, then roll back earlier repositories if a later apply fails. */
257
+ export async function applyNestedPatches(repoRoot: string, patches: NestedRepoPatch[]): Promise<void> {
197
258
  const byRepo = new Map<string, NestedRepoPatch[]>();
198
- for (const p of patches) {
199
- if (!p.patch.trim()) continue;
200
- const group = byRepo.get(p.relativePath) ?? [];
201
- group.push(p);
202
- byRepo.set(p.relativePath, group);
259
+ for (const patch of patches) {
260
+ if (!patch.patch.trim()) continue;
261
+ const group = byRepo.get(patch.relativePath) ?? [];
262
+ group.push(patch);
263
+ byRepo.set(patch.relativePath, group);
203
264
  }
204
265
 
205
- for (const [relativePath, repoPatches] of byRepo) {
266
+ const prepared: Array<{ relativePath: string; nestedDir: string; combinedPatch: string }> = [];
267
+ for (const [relativePath, repoPatches] of [...byRepo.entries()].sort(([left], [right]) =>
268
+ left.localeCompare(right),
269
+ )) {
206
270
  const nestedDir = path.join(repoRoot, relativePath);
207
271
  try {
208
272
  await fs.access(path.join(nestedDir, ".git"));
209
273
  } catch {
210
- continue;
274
+ throw new Error(`Nested repository is unavailable: ${relativePath}`);
211
275
  }
212
-
213
- const combinedDiff = repoPatches.map(p => p.patch).join("\n");
214
- for (const { patch } of repoPatches) {
215
- await git.patch.applyText(nestedDir, patch);
276
+ const combinedPatch = git.patch.join(repoPatches.map(entry => entry.patch));
277
+ if (!(await git.patch.canApplyText(nestedDir, combinedPatch))) {
278
+ throw new Error(`Nested repository patch does not apply cleanly: ${relativePath}`);
216
279
  }
280
+ prepared.push({ relativePath, nestedDir, combinedPatch });
281
+ }
217
282
 
218
- // Commit so nested repo history reflects the task changes
219
- if ((await git.status(nestedDir)).trim().length > 0) {
220
- const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
221
- await git.stage.files(nestedDir);
222
- await git.commit(nestedDir, msg);
283
+ const applied: typeof prepared = [];
284
+ try {
285
+ for (const entry of prepared) {
286
+ await git.patch.applyText(entry.nestedDir, entry.combinedPatch);
287
+ applied.push(entry);
288
+ }
289
+ } catch (error) {
290
+ const rollbackFailures: string[] = [];
291
+ for (const entry of applied.reverse()) {
292
+ try {
293
+ await git.patch.applyText(entry.nestedDir, entry.combinedPatch, { reverse: true });
294
+ } catch {
295
+ rollbackFailures.push(entry.relativePath);
296
+ }
223
297
  }
298
+ const message = error instanceof Error ? error.message : String(error);
299
+ const rollback = rollbackFailures.length
300
+ ? `; rollback failed for: ${rollbackFailures.join(", ")}`
301
+ : "; earlier nested patches were rolled back";
302
+ throw new Error(`Nested repository patch application failed: ${message}${rollback}`);
224
303
  }
225
304
  }
226
305
 
@@ -182,12 +182,15 @@ function renderSubagentSnapshotBody(
182
182
  lines.push(` ${theme.fg("dim", `Agent: ${snapshot.agent} (${snapshot.agentSource})`)}`);
183
183
  }
184
184
  if (snapshot.effectiveModel) {
185
+ // Fast mode is a property of the tier this model runs under, so the ⚡ glyph
186
+ // belongs on the model line rather than the id.
187
+ const fastSuffix = snapshot.fastMode && theme.icon.fast ? ` ${theme.icon.fast}` : "";
185
188
  if (snapshot.modelFellBack && snapshot.requestedModel) {
186
189
  lines.push(
187
- ` ${theme.fg("warning", `Model: ${snapshot.effectiveModel} (requested ${snapshot.requestedModel}, fell back — no credentials)`)}`,
190
+ ` ${theme.fg("warning", `Model: ${snapshot.effectiveModel} (requested ${snapshot.requestedModel}, fell back — no credentials)`)}${fastSuffix}`,
188
191
  );
189
192
  } else {
190
- lines.push(` ${theme.fg("dim", `Model: ${snapshot.effectiveModel}`)}`);
193
+ lines.push(` ${theme.fg("dim", `Model: ${snapshot.effectiveModel}`)}${fastSuffix}`);
191
194
  }
192
195
  }
193
196
  if (snapshot.description) lines.push(` ${theme.fg("dim", `Description: ${snapshot.description}`)}`);
@@ -84,6 +84,8 @@ export interface SubagentSnapshot {
84
84
  requestedModel?: string;
85
85
  /** True when the requested model lacked credentials and fell back to the parent model. */
86
86
  modelFellBack?: boolean;
87
+ /** True when the effective subagent provider is in fast mode. */
88
+ fastMode?: boolean;
87
89
  }
88
90
 
89
91
  export type SubagentAwaitOutcome = "completed" | "timed_out" | "interrupted";
@@ -698,6 +700,7 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
698
700
  if (record.effectiveModel) fields.effectiveModel = record.effectiveModel;
699
701
  if (record.requestedModel) fields.requestedModel = record.requestedModel;
700
702
  if (record.modelFellBack) fields.modelFellBack = true;
703
+ if (record.fastMode) fields.fastMode = true;
701
704
  return fields;
702
705
  }
703
706
 
@@ -906,6 +909,7 @@ function canonicalizeSnapshotForSignature(snapshot: SubagentSnapshot): unknown {
906
909
  effectiveModel: snapshot.effectiveModel ?? null,
907
910
  requestedModel: snapshot.requestedModel ?? null,
908
911
  modelFellBack: snapshot.modelFellBack ?? false,
912
+ fastMode: snapshot.fastMode ?? false,
909
913
  // durationMs intentionally excluded (time-derived; would defeat idle gating).
910
914
  progress: snapshot.progress ? canonicalizeProgressForSignature(snapshot.progress) : null,
911
915
  };
@@ -933,6 +937,10 @@ function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
933
937
  cost: progress.cost,
934
938
  modelOverride: progress.modelOverride ?? null,
935
939
  modelSubstitutionWarning: progress.modelSubstitutionWarning ?? null,
940
+ // The nested task panel renders this, so it must reach the signature or a
941
+ // fast-mode-only change would render differently while comparing byte-identical,
942
+ // suppressing the very update that introduces the glyph.
943
+ fastMode: progress.fastMode ?? false,
936
944
  // durationMs intentionally excluded (time-derived).
937
945
  extractedToolData: progress.extractedToolData
938
946
  ? canonicalizeExtractedToolDataForSignature(progress.extractedToolData)
package/src/utils/git.ts CHANGED
@@ -85,6 +85,7 @@ export interface PushOptions {
85
85
  export interface PatchOptions {
86
86
  readonly cached?: boolean;
87
87
  readonly check?: boolean;
88
+ readonly reverse?: boolean;
88
89
  readonly env?: Record<string, string | undefined>;
89
90
  readonly signal?: AbortSignal;
90
91
  }
@@ -358,6 +359,7 @@ function buildApplyArgs(patchPath: string, options: PatchOptions): string[] {
358
359
  const args = ["apply"];
359
360
  if (options.check) args.push("--check");
360
361
  if (options.cached) args.push("--cached");
362
+ if (options.reverse) args.push("--reverse");
361
363
  args.push("--binary", patchPath);
362
364
  return args;
363
365
  }