@gajae-code/coding-agent 0.12.11 → 0.12.13

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 (140) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli/notify-cli.d.ts +42 -1
  4. package/dist/types/cli/update-cli.d.ts +9 -3
  5. package/dist/types/cli.d.ts +10 -0
  6. package/dist/types/commands/notify.d.ts +6 -0
  7. package/dist/types/config/file-lock-gc.d.ts +13 -1
  8. package/dist/types/config/file-lock.d.ts +3 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/models-config-schema.d.ts +99 -0
  11. package/dist/types/coordinator/contract.d.ts +1 -1
  12. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  14. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  15. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  16. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  17. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  18. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  19. package/dist/types/main.d.ts +6 -2
  20. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  21. package/dist/types/modes/components/session-selector.d.ts +1 -1
  22. package/dist/types/modes/utils/host-status.d.ts +23 -0
  23. package/dist/types/runtime-mcp/types.d.ts +2 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  25. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  26. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  28. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  29. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  30. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  31. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  32. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  33. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  34. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  35. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  36. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  37. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  38. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
  42. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  43. package/dist/types/sdk/host/host.d.ts +52 -0
  44. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  45. package/dist/types/sdk/session-activation.d.ts +41 -0
  46. package/dist/types/sdk/session.d.ts +18 -0
  47. package/dist/types/sdk/startup-capability.d.ts +8 -1
  48. package/dist/types/session/agent-session.d.ts +7 -2
  49. package/dist/types/session/artifacts.d.ts +2 -2
  50. package/dist/types/session/session-manager.d.ts +40 -6
  51. package/dist/types/tools/index.d.ts +15 -0
  52. package/dist/types/utils/npm-registry.d.ts +71 -0
  53. package/examples/extensions/README.md +38 -4
  54. package/examples/extensions/ooo-bridge.ts +15 -0
  55. package/package.json +8 -8
  56. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  57. package/src/async/job-manager.ts +9 -2
  58. package/src/cli/fast-help.ts +1 -0
  59. package/src/cli/marketplace-hint.ts +39 -0
  60. package/src/cli/notify-cli.ts +226 -2
  61. package/src/cli/plugin-cli.ts +10 -0
  62. package/src/cli/update-cli.ts +90 -23
  63. package/src/cli.ts +23 -0
  64. package/src/commands/notify.ts +27 -3
  65. package/src/commands/sdk.ts +1 -0
  66. package/src/config/file-lock-gc.ts +65 -26
  67. package/src/config/file-lock.ts +61 -16
  68. package/src/config/model-profiles.ts +46 -30
  69. package/src/config/model-registry.ts +17 -5
  70. package/src/config/models-config-schema.ts +9 -4
  71. package/src/coordinator/contract.ts +1 -0
  72. package/src/coordinator-mcp/question-state.ts +2 -0
  73. package/src/coordinator-mcp/server.ts +358 -7
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  76. package/src/extensibility/extensions/index.ts +1 -0
  77. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  78. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  79. package/src/extensibility/extensions/runner.ts +15 -2
  80. package/src/extensibility/extensions/types.ts +2 -0
  81. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  82. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  83. package/src/gjc-runtime/gc-render.ts +8 -1
  84. package/src/gjc-runtime/gc-runtime.ts +15 -1
  85. package/src/gjc-runtime/launch-tmux.ts +53 -1
  86. package/src/internal-urls/docs-index.generated.ts +14 -13
  87. package/src/main.ts +214 -70
  88. package/src/modes/DESIGN.md +8 -5
  89. package/src/modes/acp/acp-agent.ts +123 -7
  90. package/src/modes/components/footer.ts +10 -18
  91. package/src/modes/components/model-selector.ts +52 -5
  92. package/src/modes/components/session-selector.ts +26 -3
  93. package/src/modes/components/status-line/gh.ts +23 -2
  94. package/src/modes/controllers/event-controller.ts +3 -0
  95. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  96. package/src/modes/controllers/input-controller.ts +11 -0
  97. package/src/modes/controllers/selector-controller.ts +8 -2
  98. package/src/modes/interactive-mode.ts +7 -1
  99. package/src/modes/print-mode.ts +10 -0
  100. package/src/modes/utils/host-status.ts +38 -0
  101. package/src/prompts/system/project-prompt.md +19 -0
  102. package/src/runtime-mcp/types.ts +2 -0
  103. package/src/sdk/broker/lifecycle.ts +60 -3
  104. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  105. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  106. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  107. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  108. package/src/sdk/bus/control-plane-frames.ts +51 -0
  109. package/src/sdk/bus/conversation-store.ts +29 -1
  110. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  111. package/src/sdk/bus/html-format.ts +19 -3
  112. package/src/sdk/bus/index.ts +91 -12
  113. package/src/sdk/bus/notification-service.ts +28 -1
  114. package/src/sdk/bus/rich-render.ts +3 -2
  115. package/src/sdk/bus/slack-daemon.ts +99 -4
  116. package/src/sdk/bus/slack-live-provider.ts +32 -2
  117. package/src/sdk/bus/slack-provider.ts +11 -0
  118. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  119. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  120. package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
  121. package/src/sdk/bus/telegram-daemon.ts +2007 -509
  122. package/src/sdk/bus/telegram-reference.ts +114 -15
  123. package/src/sdk/bus/topic-registry.ts +674 -181
  124. package/src/sdk/host/host.ts +180 -1
  125. package/src/sdk/lifecycle-session.ts +8 -2
  126. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  127. package/src/sdk/session-activation.ts +127 -0
  128. package/src/sdk/session.ts +144 -29
  129. package/src/sdk/startup-capability.ts +10 -1
  130. package/src/session/agent-session.ts +220 -13
  131. package/src/session/artifacts.ts +56 -13
  132. package/src/session/internal/managed-session-storage.ts +228 -7
  133. package/src/session/session-manager.ts +225 -60
  134. package/src/setup/provider-presets.json +3 -3
  135. package/src/task/index.ts +92 -44
  136. package/src/thinking-metadata.ts +2 -1
  137. package/src/tools/ask.ts +10 -0
  138. package/src/tools/index.ts +15 -0
  139. package/src/tools/todo-write.ts +4 -4
  140. package/src/utils/npm-registry.ts +660 -0
@@ -0,0 +1,235 @@
1
+ import type { MCPServerConnection, MCPToolCallResult } from "../../runtime-mcp";
2
+ import { callTool, connectToServer, disconnectServer } from "../../runtime-mcp";
3
+ import { createExactPrefixCommandBridge } from "./prefix-command-bridge";
4
+ import type { ExtensionContext, InputEvent, InputEventResult } from "./types";
5
+
6
+ const OUROBOROS_CLI_ENV = "OUROBOROS_CLI";
7
+ const INTERVIEW_COMMAND = "ooo interview";
8
+ const INTERVIEW_TOOL = "ouroboros_interview";
9
+ const INTERVIEW_SESSION_PATTERN = /^interview_[A-Za-z0-9_-]+$/;
10
+
11
+ interface OuroborosOooBridgeOptions {
12
+ connect?: typeof connectToServer;
13
+ callTool?: typeof callTool;
14
+ disconnect?: typeof disconnectServer;
15
+ }
16
+
17
+ interface InterviewState {
18
+ sessionId: string;
19
+ }
20
+
21
+ interface OuroborosOooBridgeHandler {
22
+ (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult>;
23
+ reset(): Promise<void>;
24
+ }
25
+
26
+ function resolveOuroborosCommand(): string {
27
+ return process.env[OUROBOROS_CLI_ENV]?.trim() || "ouroboros";
28
+ }
29
+
30
+ function interviewArgument(text: string): string | undefined {
31
+ if (text === INTERVIEW_COMMAND) return "";
32
+ if (text.startsWith(`${INTERVIEW_COMMAND} `) || text.startsWith(`${INTERVIEW_COMMAND}\t`)) {
33
+ return text.slice(INTERVIEW_COMMAND.length).trim();
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ function isOooCommand(text: string): boolean {
39
+ return text === "ooo" || text.startsWith("ooo ") || text.startsWith("ooo\t");
40
+ }
41
+
42
+ function isBuiltInControlInput(text: string): boolean {
43
+ return text === "." || text === "c" || text.startsWith("/");
44
+ }
45
+
46
+ function resetsInterviewState(text: string): boolean {
47
+ return /^\/(?:clear|drop|exit|new|quit)(?:\s|$)/.test(text);
48
+ }
49
+
50
+ function resultText(result: MCPToolCallResult): string {
51
+ return result.content
52
+ .filter(content => content.type === "text")
53
+ .map(content => content.text)
54
+ .join("\n\n")
55
+ .trim();
56
+ }
57
+
58
+ function resultMeta(result: MCPToolCallResult): Record<string, unknown> {
59
+ return result._meta ?? {};
60
+ }
61
+
62
+ function resultSessionId(result: MCPToolCallResult, text: string): string | undefined {
63
+ const metadataSessionId = resultMeta(result).session_id;
64
+ if (typeof metadataSessionId === "string" && INTERVIEW_SESSION_PATTERN.test(metadataSessionId)) {
65
+ return metadataSessionId;
66
+ }
67
+ const textSessionId = /\bSession(?: ID)?:\s*(interview_[A-Za-z0-9_-]+)/.exec(text)?.[1];
68
+ return textSessionId && INTERVIEW_SESSION_PATTERN.test(textSessionId) ? textSessionId : undefined;
69
+ }
70
+
71
+ function resultCompleted(result: MCPToolCallResult): boolean {
72
+ const meta = resultMeta(result);
73
+ return meta.completed === true || meta.phase === "complete";
74
+ }
75
+
76
+ export function createOuroborosOooBridge(options: OuroborosOooBridgeOptions = {}): OuroborosOooBridgeHandler {
77
+ const connect = options.connect ?? connectToServer;
78
+ const invoke = options.callTool ?? callTool;
79
+ const disconnect = options.disconnect ?? disconnectServer;
80
+ let interview: InterviewState | undefined;
81
+ let interviewCaptureActive = false;
82
+ let activeConnection: MCPServerConnection | undefined;
83
+ let pendingConnection: Promise<MCPServerConnection> | undefined;
84
+ let activeOperationAbort: AbortController | undefined;
85
+ let lifecycleGeneration = 0;
86
+ let operationTail: Promise<void> = Promise.resolve();
87
+
88
+ const commandBridge = createExactPrefixCommandBridge({
89
+ prefix: "ooo",
90
+ command: resolveOuroborosCommand(),
91
+ args: ["dispatch", "--runtime", "gjc"],
92
+ });
93
+
94
+ function assertCurrent(generation: number, signal: AbortSignal | undefined): void {
95
+ if (generation !== lifecycleGeneration || signal?.aborted) {
96
+ throw signal?.reason instanceof Error ? signal.reason : new Error("Ouroboros interview operation cancelled");
97
+ }
98
+ }
99
+
100
+ async function disconnectSafely(connection: MCPServerConnection | undefined): Promise<void> {
101
+ if (!connection) return;
102
+ try {
103
+ await disconnect(connection);
104
+ } catch {
105
+ // State is already fenced. A dead transport must not keep ordinary input captured.
106
+ }
107
+ }
108
+
109
+ async function resetInterview(): Promise<void> {
110
+ lifecycleGeneration++;
111
+ interviewCaptureActive = false;
112
+ const operationAbort = activeOperationAbort;
113
+ activeOperationAbort = undefined;
114
+ operationAbort?.abort(new Error("Ouroboros interview reset"));
115
+ const connectionToClose = activeConnection;
116
+ interview = undefined;
117
+ activeConnection = undefined;
118
+ pendingConnection = undefined;
119
+ await disconnectSafely(connectionToClose);
120
+ }
121
+
122
+ async function connection(ctx: ExtensionContext, generation: number): Promise<MCPServerConnection> {
123
+ assertCurrent(generation, ctx.signal);
124
+ if (activeConnection) return activeConnection;
125
+ const pending =
126
+ pendingConnection ??
127
+ connect(
128
+ "ouroboros-ooo-bridge",
129
+ {
130
+ type: "stdio",
131
+ command: resolveOuroborosCommand(),
132
+ args: ["mcp", "serve", "--runtime", "gjc"],
133
+ cwd: ctx.cwd,
134
+ },
135
+ { signal: ctx.signal },
136
+ );
137
+ pendingConnection = pending;
138
+ try {
139
+ const connected = await pending;
140
+ try {
141
+ assertCurrent(generation, ctx.signal);
142
+ } catch (error) {
143
+ await disconnectSafely(connected);
144
+ throw error;
145
+ }
146
+ activeConnection = connected;
147
+ return connected;
148
+ } finally {
149
+ if (pendingConnection === pending) pendingConnection = undefined;
150
+ }
151
+ }
152
+
153
+ async function runInterview(text: string, ctx: ExtensionContext): Promise<InputEventResult> {
154
+ const operationAbort = new AbortController();
155
+ activeOperationAbort = operationAbort;
156
+ const operationSignal = ctx.signal ? AbortSignal.any([ctx.signal, operationAbort.signal]) : operationAbort.signal;
157
+ const operationContext: ExtensionContext = { ...ctx, signal: operationSignal };
158
+ const generation = lifecycleGeneration;
159
+ const abortHandler = () => {
160
+ void resetInterview();
161
+ };
162
+ ctx.signal?.addEventListener("abort", abortHandler, { once: true });
163
+ try {
164
+ const interviewConnection = await connection(operationContext, generation);
165
+ const commandArgument = interviewArgument(text);
166
+ const args: Record<string, unknown> = { cwd: ctx.cwd };
167
+ if (interview) {
168
+ args.session_id = interview.sessionId;
169
+ const answer = commandArgument === undefined ? text.trim() : commandArgument;
170
+ if (answer) args.answer = answer;
171
+ } else {
172
+ args.initial_context = commandArgument ?? "";
173
+ }
174
+
175
+ const result = await invoke(interviewConnection, INTERVIEW_TOOL, args, { signal: operationSignal });
176
+ assertCurrent(generation, operationSignal);
177
+ const output = resultText(result);
178
+ if (result.isError) throw new Error(output || "Ouroboros interview failed");
179
+
180
+ const sessionId = resultSessionId(result, output);
181
+ if (!resultCompleted(result)) {
182
+ if (!sessionId) throw new Error("Ouroboros interview response did not include a session ID");
183
+ interview = { sessionId };
184
+ } else {
185
+ await resetInterview();
186
+ }
187
+ return output ? { handled: true, text: output } : { handled: true };
188
+ } catch (error) {
189
+ await resetInterview();
190
+ if (!ctx.signal?.aborted) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ ctx.ui?.notify(message, "error");
193
+ }
194
+ return { handled: true };
195
+ } finally {
196
+ ctx.signal?.removeEventListener("abort", abortHandler);
197
+ if (activeOperationAbort === operationAbort) activeOperationAbort = undefined;
198
+ }
199
+ }
200
+
201
+ function enqueueInterview(
202
+ text: string,
203
+ ctx: ExtensionContext,
204
+ explicitInterview: boolean,
205
+ ): Promise<InputEventResult> {
206
+ const submissionGeneration = lifecycleGeneration;
207
+ const operation = operationTail.then(async () => {
208
+ if (submissionGeneration !== lifecycleGeneration) return { handled: true };
209
+ if (!explicitInterview && !interviewCaptureActive && !interview) return { handled: true };
210
+ return runInterview(text, ctx);
211
+ });
212
+ operationTail = operation.then(
213
+ () => undefined,
214
+ () => undefined,
215
+ );
216
+ return operation;
217
+ }
218
+
219
+ const handler = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> => {
220
+ if (event.source !== undefined && event.source !== "interactive") return {};
221
+ if (isBuiltInControlInput(event.text)) {
222
+ if (resetsInterviewState(event.text)) await resetInterview();
223
+ return {};
224
+ }
225
+ const argument = interviewArgument(event.text);
226
+ const explicitInterview = argument !== undefined;
227
+ if (explicitInterview) interviewCaptureActive = true;
228
+ if (explicitInterview || ((interviewCaptureActive || interview) && !isOooCommand(event.text))) {
229
+ return enqueueInterview(event.text, ctx, explicitInterview);
230
+ }
231
+ return commandBridge(event, ctx);
232
+ };
233
+
234
+ return Object.assign(handler, { reset: resetInterview });
235
+ }
@@ -86,7 +86,10 @@ export function createExactPrefixCommandBridge(options: ExactPrefixCommandBridge
86
86
  process.env[recursionEnv] = nextRecursionDepth(recursionEnv);
87
87
  try {
88
88
  const result = await dispatch(options.command, [...args, event.text], ctx, { timeout });
89
- if (result.code === 0) return { handled: true };
89
+ if (result.code === 0) {
90
+ const output = result.stdout.trim() || result.stderr.trim();
91
+ return output ? { handled: true, text: output } : { handled: true };
92
+ }
90
93
  if (result.code === continueExitCode) return {};
91
94
 
92
95
  const output =
@@ -118,11 +121,3 @@ export function createExactPrefixCommandBridge(options: ExactPrefixCommandBridge
118
121
  }
119
122
  };
120
123
  }
121
-
122
- export function createOuroborosOooBridge() {
123
- return createExactPrefixCommandBridge({
124
- prefix: "ooo",
125
- command: "ouroboros",
126
- args: ["dispatch"],
127
- });
128
- }
@@ -79,6 +79,16 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
79
79
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
80
80
 
81
81
  const MAX_PENDING_CREDENTIAL_DISABLED = 32;
82
+ function createHandlerContext(ctx: ExtensionContext, signal: AbortSignal): ExtensionContext {
83
+ const descriptors = Object.getOwnPropertyDescriptors(ctx);
84
+ descriptors.signal = {
85
+ configurable: true,
86
+ enumerable: true,
87
+ writable: true,
88
+ value: signal,
89
+ };
90
+ return Object.defineProperties({}, descriptors) as ExtensionContext;
91
+ }
82
92
 
83
93
  /**
84
94
  * Events handled by the generic emit() method.
@@ -688,12 +698,14 @@ export class ExtensionRunner {
688
698
  ext: Extension,
689
699
  timeoutMs: number,
690
700
  ): Promise<TResult | undefined> {
691
- let timeout: ReturnType<typeof setTimeout> | undefined;
701
+ let timeout: NodeJS.Timeout | undefined;
702
+ const abortController = new AbortController();
703
+ const handlerContext = createHandlerContext(ctx, abortController.signal);
692
704
  try {
693
705
  const timeoutPromise = new Promise<typeof EXTENSION_HANDLER_TIMEOUT>(resolve => {
694
706
  timeout = setTimeout(() => resolve(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
695
707
  });
696
- const handlerResult = await Promise.race([Promise.resolve(handler(event, ctx)), timeoutPromise]);
708
+ const handlerResult = await Promise.race([Promise.resolve(handler(event, handlerContext)), timeoutPromise]);
697
709
  if (timeout !== undefined) {
698
710
  clearTimeout(timeout);
699
711
  timeout = undefined;
@@ -701,6 +713,7 @@ export class ExtensionRunner {
701
713
 
702
714
  if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
703
715
  const error = `handler timed out after ${timeoutMs}ms`;
716
+ abortController.abort(new Error(error));
704
717
  logger.warn("Extension handler timed out", {
705
718
  extensionPath: ext.path,
706
719
  event: event.type,
@@ -352,6 +352,8 @@ export interface ExtensionContext {
352
352
  hasUI: boolean;
353
353
  /** Current working directory */
354
354
  cwd: string;
355
+ /** Aborted when the runner stops waiting for this handler, including handler timeout. */
356
+ signal?: AbortSignal;
355
357
  /** Session manager (read-only) */
356
358
  sessionManager: ReadonlySessionManager;
357
359
  /** Session classification supplied by the SDK for extension policy decisions. */
@@ -11,6 +11,7 @@ import {
11
11
  assertDeepInterviewInputWithinLimit,
12
12
  assertDeepInterviewIntentManifest,
13
13
  assertDeepInterviewStructuredResponseWithinLimit,
14
+ canonicalizeDeepInterviewText,
14
15
  createDeepInterviewIntentManifest,
15
16
  type DeepInterviewEstablishedFact,
16
17
  type DeepInterviewIntentItem,
@@ -106,16 +107,19 @@ export function buildAnswerShell(
106
107
  input: DeepInterviewAnswerInput,
107
108
  now: string = new Date().toISOString(),
108
109
  ): DeepInterviewRoundRecord {
110
+ const questionText = canonicalizeDeepInterviewText(input.questionText);
111
+ const selectedOptions = input.selectedOptions?.map(canonicalizeDeepInterviewText);
112
+ const customInput = input.customInput === undefined ? undefined : canonicalizeDeepInterviewText(input.customInput);
109
113
  return {
110
114
  round_key: deriveRoundKey(input.interviewId, input),
111
115
  round_id: input.round_id,
112
116
  round: input.round,
113
117
  question_id: input.questionId,
114
- question_text: input.questionText,
115
- question_hash: questionHash(input.questionText),
116
- answer_hash: answerHash(input.selectedOptions, input.customInput),
117
- selected_options: input.selectedOptions,
118
- custom_input: input.customInput,
118
+ question_text: questionText,
119
+ question_hash: questionHash(questionText),
120
+ answer_hash: answerHash(selectedOptions, customInput),
121
+ selected_options: selectedOptions,
122
+ custom_input: customInput,
119
123
  component: input.component,
120
124
  dimension: input.dimension,
121
125
  ambiguity_at_ask: input.ambiguity,
@@ -97,13 +97,34 @@ export function hashContent(value: string): string {
97
97
  return createHash("sha256").update(value).digest("hex").slice(0, 32);
98
98
  }
99
99
 
100
+ /**
101
+ * Canonicalize interview prose to NFC.
102
+ *
103
+ * Hangul, unlike most Latin prose, routinely reaches the interview in both
104
+ * composed and decomposed form (macOS-sourced pastes and some IME/clipboard
105
+ * paths emit NFD), and the two forms are the same text for the user while
106
+ * being different JavaScript strings. Canonicalizing at the identity and
107
+ * length boundaries keeps one round per answer and keeps the character caps
108
+ * script-fair: decomposed Hangul otherwise costs two to three code points per
109
+ * syllable against the same budget.
110
+ */
111
+ export function canonicalizeDeepInterviewText(value: string): string {
112
+ return value.normalize("NFC");
113
+ }
114
+
100
115
  export function questionHash(questionText: string): string {
101
- return hashContent(questionText);
116
+ return hashContent(canonicalizeDeepInterviewText(questionText));
102
117
  }
103
118
 
104
119
  export function answerHash(selectedOptions: string[] | undefined, customInput: string | undefined): string {
105
120
  return createHash("sha256")
106
- .update(JSON.stringify({ selected: selectedOptions ?? [], custom: customInput ?? null }))
121
+ .update(
122
+ JSON.stringify({
123
+ selected: (selectedOptions ?? []).map(canonicalizeDeepInterviewText),
124
+ custom:
125
+ customInput === undefined || customInput === null ? null : canonicalizeDeepInterviewText(customInput),
126
+ }),
127
+ )
107
128
  .digest("hex");
108
129
  }
109
130
 
@@ -471,10 +492,13 @@ export function assertDeepInterviewStructuredResponseWithinLimit(value: unknown)
471
492
  /**
472
493
  * Assert a free-text input is within its size cap. Never inspects content for shell
473
494
  * metacharacters — free-text fields accept prose verbatim; this only bounds length.
495
+ * The cap is measured on the NFC form so decomposed Hangul is charged the same
496
+ * budget as the identical composed text.
474
497
  */
475
498
  export function assertDeepInterviewInputWithinLimit(value: string, max: number, fieldName = "input"): void {
476
499
  if (typeof value !== "string") throw new Error(`${fieldName} must be a string`);
477
- if (deepInterviewCharacterCount(value) > max) throw new Error(`${fieldName} exceeds max length ${max}`);
500
+ if (deepInterviewCharacterCount(canonicalizeDeepInterviewText(value)) > max)
501
+ throw new Error(`${fieldName} exceeds max length ${max}`);
478
502
  }
479
503
 
480
504
  /** Validate user-supplied deep-interview prose before an envelope is persisted. */
@@ -72,6 +72,12 @@ export function buildGcReportText(report: GcReport): string {
72
72
  lines.push("");
73
73
  }
74
74
 
75
+ if (report.warnings.length > 0) {
76
+ lines.push(`Warnings (${report.warnings.length})`);
77
+ for (const warning of report.warnings) lines.push(` [${warning.store}/${warning.scope}] ${warning.message}`);
78
+ lines.push("");
79
+ }
80
+
75
81
  if (report.errors.length > 0) {
76
82
  lines.push(`Errors (${report.errors.length})`);
77
83
  for (const err of report.errors) lines.push(` [${err.store}/${err.scope}] ${err.message}`);
@@ -82,7 +88,8 @@ export function buildGcReportText(report: GcReport): string {
82
88
  lines.push(
83
89
  `Summary: discovered=${c.discovered} stale=${c.stale} alive=${c.alive} eperm=${c.eperm} unknown=${c.unknown} ` +
84
90
  `terminal_lifecycle=${c.terminal_lifecycle} unclassified=${c.unclassified} ` +
85
- `${report.dry_run ? `would_remove=${c.would_remove}` : `removed=${c.removed} failed=${c.failed}`} errors=${c.errors}`,
91
+ `${report.dry_run ? `would_remove=${c.would_remove}` : `removed=${c.removed} failed=${c.failed}`} ` +
92
+ `errors=${c.errors} warnings=${report.warnings.length}`,
86
93
  );
87
94
  lines.push("");
88
95
  return `${lines.join("\n")}`;
@@ -78,9 +78,18 @@ export interface GcError {
78
78
  message: string;
79
79
  }
80
80
 
81
+ /** Non-fatal discovery partials (e.g. traversal caps). Does not affect exit code. */
82
+ export interface GcWarning {
83
+ store: GcStore;
84
+ scope: string;
85
+ message: string;
86
+ }
87
+
81
88
  export interface GcCollectResult {
82
89
  records: GcRecord[];
83
90
  errors: GcError[];
91
+ /** Optional partial-result notices; omitted by adapters that have none. */
92
+ warnings?: GcWarning[];
84
93
  }
85
94
 
86
95
  export interface GcPruneOutcome {
@@ -140,6 +149,8 @@ export interface GcReport {
140
149
  stores: Record<GcStore, GcRecord[]>;
141
150
  counts: GcCounts;
142
151
  errors: GcError[];
152
+ /** Partial-result notices that do not fail the run (e.g. walk caps). */
153
+ warnings: GcWarning[];
143
154
  session_index?: GcSessionIndexHealth;
144
155
  }
145
156
 
@@ -298,12 +309,14 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
298
309
  export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext, prune: boolean): Promise<GcReport> {
299
310
  const stores = emptyStores();
300
311
  const errors: GcError[] = [];
312
+ const warnings: GcWarning[] = [];
301
313
 
302
314
  for (const adapter of adapters) {
303
315
  try {
304
316
  const result = await adapter.collect(ctx);
305
317
  stores[adapter.store].push(...result.records);
306
318
  errors.push(...result.errors);
319
+ if (result.warnings) warnings.push(...result.warnings);
307
320
  } catch (error) {
308
321
  errors.push({
309
322
  store: adapter.store,
@@ -350,7 +363,7 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
350
363
  }
351
364
  }
352
365
 
353
- return { dry_run: !prune, stores, counts: computeCounts(stores, errors), errors };
366
+ return { dry_run: !prune, stores, counts: computeCounts(stores, errors), errors, warnings };
354
367
  }
355
368
 
356
369
  /**
@@ -358,6 +371,7 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
358
371
  * - usage/parse error => 2
359
372
  * - hard discovery errors => 1 (both modes)
360
373
  * - prune mode with a failed intended removal => 1
374
+ * - warnings alone never fail the run
361
375
  * - otherwise => 0
362
376
  */
363
377
  export function computeExitCode(report: GcReport): number {
@@ -652,6 +652,56 @@ function renameTmuxWindow(
652
652
  spawnSync(tmuxCommand, buildTmuxRenameWindowArgs(title, target), options);
653
653
  }
654
654
 
655
+ interface TmuxWindowIdentity {
656
+ paneId: string;
657
+ windowId: string;
658
+ windowIndex: string;
659
+ }
660
+
661
+ function parseTmuxWindowIdentity(value: string): TmuxWindowIdentity | null {
662
+ const [paneId, windowId, windowIndex, extra] = value.trim().split("\t");
663
+ if (
664
+ extra !== undefined ||
665
+ paneId === undefined ||
666
+ windowId === undefined ||
667
+ windowIndex === undefined ||
668
+ !/^%\d+$/.test(paneId) ||
669
+ !/^@\d+$/.test(windowId) ||
670
+ !/^\d+$/.test(windowIndex)
671
+ )
672
+ return null;
673
+ return { paneId, windowId, windowIndex };
674
+ }
675
+
676
+ function quoteTmuxCommandArgument(value: string): string {
677
+ return `'${value.replaceAll("'", "'\\''")}'`;
678
+ }
679
+
680
+ function renameExistingTmuxWindow(
681
+ tmuxCommand: string,
682
+ paneId: string,
683
+ title: string,
684
+ spawnSync: TmuxSpawnSync,
685
+ options: TmuxSpawnOptions,
686
+ ): void {
687
+ if (!/^%\d+$/.test(paneId)) return;
688
+ const observed = spawnSync(
689
+ tmuxCommand,
690
+ ["display-message", "-p", "-t", paneId, "#{pane_id}\t#{window_id}\t#{window_index}"],
691
+ options,
692
+ );
693
+ if (observed.exitCode !== 0) return;
694
+ const identity = parseTmuxWindowIdentity(observed.stdout ?? "");
695
+ if (!identity || identity.paneId !== paneId) return;
696
+
697
+ // `if-shell -F` evaluates the pane binding and inserts the rename into the
698
+ // same tmux command queue. The nested command targets the immutable window
699
+ // id, so an active-window switch or index reuse cannot redirect the rename.
700
+ const predicate = `#{&&:#{==:#{pane_id},${identity.paneId}},#{&&:#{==:#{window_id},${identity.windowId}},#{==:#{window_index},${identity.windowIndex}}}}`;
701
+ const command = `rename-window -t ${identity.windowId} -- ${quoteTmuxCommandArgument(title)}`;
702
+ spawnSync(tmuxCommand, ["if-shell", "-t", identity.paneId, "-F", predicate, command], options);
703
+ }
704
+
655
705
  function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
656
706
  const env = context.env ?? process.env;
657
707
  if (!env.TMUX || env[GJC_TMUX_LAUNCHED_ENV] === "1") return;
@@ -669,11 +719,13 @@ function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
669
719
  const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
670
720
  if (!tmuxAvailable) return;
671
721
 
722
+ const paneId = env.TMUX_PANE?.trim();
723
+ if (!paneId) return;
672
724
  const cwd = context.cwd ?? process.cwd();
673
725
  const branch = context.worktreeBranch ?? context.currentBranch ?? readCurrentBranch(cwd);
674
726
  const title = buildGjcTmuxWindowTitle(context.project ?? cwd, branch);
675
727
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
676
- renameTmuxWindow(tmuxCommand, title, spawnSync, {
728
+ renameExistingTmuxWindow(tmuxCommand, paneId, title, spawnSync, {
677
729
  cwd,
678
730
  env,
679
731
  stdin: "pipe",