@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.
- package/CHANGELOG.md +68 -0
- package/dist/types/cli/marketplace-hint.d.ts +28 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock-gc.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/models-config-schema.d.ts +99 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/acp/acp-agent.d.ts +24 -1
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/html-format.d.ts +10 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
- package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +40 -6
- package/dist/types/tools/index.d.ts +15 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/marketplace-hint.ts +39 -0
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/plugin-cli.ts +10 -0
- package/src/cli/update-cli.ts +90 -23
- package/src/cli.ts +23 -0
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +65 -26
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +46 -30
- package/src/config/model-registry.ts +17 -5
- package/src/config/models-config-schema.ts +9 -4
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
- package/src/gjc-runtime/deep-interview-state.ts +27 -3
- package/src/gjc-runtime/gc-render.ts +8 -1
- package/src/gjc-runtime/gc-runtime.ts +15 -1
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/main.ts +214 -70
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/acp/acp-agent.ts +123 -7
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/model-selector.ts +52 -5
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/print-mode.ts +10 -0
- package/src/modes/utils/host-status.ts +38 -0
- package/src/prompts/system/project-prompt.md +19 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/html-format.ts +19 -3
- package/src/sdk/bus/index.ts +91 -12
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
- package/src/sdk/bus/telegram-daemon.ts +2007 -509
- package/src/sdk/bus/telegram-reference.ts +114 -15
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +144 -29
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -13
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +228 -7
- package/src/session/session-manager.ts +225 -60
- package/src/setup/provider-presets.json +3 -3
- package/src/task/index.ts +92 -44
- package/src/thinking-metadata.ts +2 -1
- package/src/tools/ask.ts +10 -0
- package/src/tools/index.ts +15 -0
- package/src/tools/todo-write.ts +4 -4
- 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)
|
|
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:
|
|
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,
|
|
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:
|
|
115
|
-
question_hash: questionHash(
|
|
116
|
-
answer_hash: answerHash(
|
|
117
|
-
selected_options:
|
|
118
|
-
custom_input:
|
|
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(
|
|
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)
|
|
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}`}
|
|
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
|
-
|
|
728
|
+
renameExistingTmuxWindow(tmuxCommand, paneId, title, spawnSync, {
|
|
677
729
|
cwd,
|
|
678
730
|
env,
|
|
679
731
|
stdin: "pipe",
|