@gajae-code/coding-agent 0.11.5 → 0.11.6

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.
@@ -853,6 +853,32 @@ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | un
853
853
  return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
854
854
  }
855
855
 
856
+ /**
857
+ * Resolve `target` through the real path of its nearest existing ancestor,
858
+ * re-appending any not-yet-created trailing segments. Symlink/reparse components
859
+ * in the existing prefix are canonicalized (so aliases cannot escape a containment
860
+ * check or alias another file), while a missing leaf (e.g. an uncommitted session
861
+ * transcript) is still resolved deterministically. Non-ENOENT errors (ELOOP,
862
+ * EACCES, ...) surface rather than silently falling back to the lexical path.
863
+ */
864
+ function canonicalizeThroughExistingAncestor(target: string): string {
865
+ const resolved = path.resolve(target);
866
+ const tail: string[] = [];
867
+ let current = resolved;
868
+ while (true) {
869
+ try {
870
+ const real = fs.realpathSync(current);
871
+ return tail.length === 0 ? real : path.join(real, ...tail.reverse());
872
+ } catch (err) {
873
+ if (!isEnoent(err)) throw err;
874
+ const parent = path.dirname(current);
875
+ if (parent === current) return resolved;
876
+ tail.push(path.basename(current));
877
+ current = parent;
878
+ }
879
+ }
880
+ }
881
+
856
882
  function isSupportedSessionVersion(version: unknown): boolean {
857
883
  return (
858
884
  version === undefined ||
@@ -4239,6 +4265,46 @@ export class SessionManager {
4239
4265
  }
4240
4266
  }
4241
4267
 
4268
+ /**
4269
+ * Exact-delete a session transcript and its artifacts by path WITHOUT requiring
4270
+ * managed logical authorization. This is only for discarding an UNCOMMITTED
4271
+ * successor that a transaction (e.g. handoff) created via `newSession()` but
4272
+ * never durably authorized — such a session has no managed candidate listing
4273
+ * yet, so `dropSession` would refuse it and leak its resident-cache/artifact
4274
+ * root. Bounded to this manager's configured session root and refuses to touch
4275
+ * the active session; tolerates missing files.
4276
+ */
4277
+ async discardUncommittedSession(sessionPath: string): Promise<void> {
4278
+ // Close the writer FIRST so the canonical checks and the delete run with no
4279
+ // intervening await, eliminating the check/use window in which a parent
4280
+ // directory could be swapped for a symlink between authorization and deletion.
4281
+ await this.#closePersistWriter();
4282
+ // Canonicalize the candidate (and the active file / configured dir) through
4283
+ // their nearest existing ancestor so symlink/reparse components cannot alias
4284
+ // the active session or escape the configured session directory, even when
4285
+ // the successor transcript itself does not yet exist.
4286
+ const canonicalCandidate = canonicalizeThroughExistingAncestor(path.resolve(sessionPath));
4287
+ if (this.#sessionFile) {
4288
+ const canonicalActive = canonicalizeThroughExistingAncestor(path.resolve(this.#sessionFile));
4289
+ if (canonicalActive === canonicalCandidate) {
4290
+ throw new Error("Refusing to discard the active session as uncommitted.");
4291
+ }
4292
+ }
4293
+ // Least-authority containment: only within this manager's configured session
4294
+ // directory (canonical), where newSession() allocates the successor. The
4295
+ // broader managed root (parent of sessionDir) is deliberately NOT accepted.
4296
+ const canonicalSessionDir = canonicalizeThroughExistingAncestor(path.resolve(this.sessionDir));
4297
+ if (!pathIsWithin(canonicalSessionDir, canonicalCandidate)) {
4298
+ throw new Error("Uncommitted session discard is limited to this manager's configured session directory.");
4299
+ }
4300
+ try {
4301
+ await this.storage.deleteSessionWithArtifacts(canonicalCandidate);
4302
+ } catch (err) {
4303
+ if (isEnoent(err)) return;
4304
+ throw err;
4305
+ }
4306
+ }
4307
+
4242
4308
  /**
4243
4309
  * Fork the current session, creating a new session file with the same entries.
4244
4310
  * Returns both the old and new session file paths for artifact copying.
@@ -107,6 +107,26 @@ export class YieldQueue {
107
107
  this.#idleFlushPending = false;
108
108
  }
109
109
 
110
+ /** Drop only the queued entries of a single kind, leaving other kinds intact. */
111
+ clearKind(kind: string): void {
112
+ this.#entries.delete(kind);
113
+ }
114
+
115
+ /**
116
+ * Re-schedule an idle flush if work remains and the session is idle. Used after
117
+ * a transition (e.g. handoff) releases a delivery fence so entries queued while
118
+ * fenced are not stranded until an unrelated enqueue or agent yield.
119
+ */
120
+ rearmIdle(): void {
121
+ if (this.#options.isStreaming()) return;
122
+ for (const entries of this.#entries.values()) {
123
+ if (entries.length > 0) {
124
+ this.#scheduleIdleFlush();
125
+ return;
126
+ }
127
+ }
128
+ }
129
+
110
130
  #scheduleIdleFlush(): void {
111
131
  if (this.#idleFlushPending) return;
112
132
  this.#idleFlushPending = true;
@@ -1435,6 +1435,42 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1435
1435
  await runtime.ctx.handleCompactCommand(customInstructions);
1436
1436
  },
1437
1437
  },
1438
+ {
1439
+ name: "handoff",
1440
+ priority: 71,
1441
+ description: "Generate a handoff and continue in a new session",
1442
+ acpDescription: "Generate a handoff document and start a new session",
1443
+ inlineHint: "[focus instructions]",
1444
+ acpInputHint: "[focus instructions]",
1445
+ allowArgs: true,
1446
+ handle: async (command, runtime) => {
1447
+ let result: Awaited<ReturnType<typeof runtime.session.handoff>>;
1448
+ try {
1449
+ result = await runtime.session.handoff(command.args || undefined);
1450
+ } catch (err) {
1451
+ // Handoff precondition failures (nothing to hand off, streaming),
1452
+ // cancellation, and provider errors propagate as plain Errors; the
1453
+ // switch is non-destructive so the current session is unchanged.
1454
+ return usage(`Handoff failed: ${errorMessage(err)}; current session is unchanged.`, runtime);
1455
+ }
1456
+ if (!result) {
1457
+ return usage(
1458
+ "Handoff not created (cancelled or nothing to hand off); current session is unchanged.",
1459
+ runtime,
1460
+ );
1461
+ }
1462
+ await runtime.output(
1463
+ result.savedPath
1464
+ ? `Handoff created; new session started. Handoff document saved to: ${result.savedPath}`
1465
+ : "Handoff created; new session started with handoff context.",
1466
+ );
1467
+ return commandConsumed();
1468
+ },
1469
+ handleTui: async (command, runtime) => {
1470
+ runtime.ctx.editor.setText("");
1471
+ await runtime.ctx.handleHandoffCommand(command.args || undefined);
1472
+ },
1473
+ },
1438
1474
  {
1439
1475
  name: "contribute-pr",
1440
1476
  aliases: ["contribution-prep"],
@@ -7,6 +7,7 @@
7
7
  * source; registering returns a disposer.
8
8
  */
9
9
 
10
+ import { logger } from "@gajae-code/utils";
10
11
  import type { WorkflowGateEmitter } from "../modes/shared/agent-wire/workflow-gate-broker";
11
12
  import type { AskAnswerSource } from "./index";
12
13
 
@@ -31,7 +32,20 @@ export function getAskAnswerSource(sessionId: string): AskAnswerSource | undefin
31
32
  export function notifyWorkflowGateEmitterChanged(sessionId: string, emitter: WorkflowGateEmitter | undefined): void {
32
33
  if (emitter) workflowGateEmitters.set(sessionId, emitter);
33
34
  else workflowGateEmitters.delete(sessionId);
34
- for (const listener of workflowGateListeners.get(sessionId) ?? []) listener(emitter);
35
+ for (const listener of workflowGateListeners.get(sessionId) ?? []) {
36
+ // Isolate each listener: a throwing observer must never escape into an
37
+ // emitter suspend/bind/restore step, which runs inside session-transition
38
+ // transactions (e.g. handoff) whose rollback/commit correctness depends on
39
+ // this notification being no-throw.
40
+ try {
41
+ listener(emitter);
42
+ } catch (error) {
43
+ logger.warn("Workflow-gate emitter listener threw during notification", {
44
+ sessionId,
45
+ error: error instanceof Error ? error.message : String(error),
46
+ });
47
+ }
48
+ }
35
49
  }
36
50
 
37
51
  /** Observe workflow-gate emitter installation even when it occurs after session_start. */