@gajae-code/coding-agent 0.11.4 → 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.
- package/CHANGELOG.md +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +388 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +1447 -852
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Automatically detects OAuth requirements from MCP server responses
|
|
5
5
|
* and extracts authentication endpoints.
|
|
6
6
|
*/
|
|
7
|
+
import type { AddressResolver, PublicUrlAccepted } from "../web/insane/url-guard";
|
|
8
|
+
import { validatePublicHttpUrl } from "../web/insane/url-guard";
|
|
7
9
|
|
|
8
10
|
export interface OAuthEndpoints {
|
|
9
11
|
authorizationUrl: string;
|
|
@@ -20,6 +22,16 @@ export interface AuthDetectionResult {
|
|
|
20
22
|
message?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
interface OAuthDiscoveryOptions {
|
|
26
|
+
fetch?: (input: string | URL | Request, init?: BunFetchRequestInit) => Promise<Response>;
|
|
27
|
+
resolver?: AddressResolver;
|
|
28
|
+
maxRequests?: number;
|
|
29
|
+
maxNodes?: number;
|
|
30
|
+
maxRedirects?: number;
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
function parseMcpAuthServerUrl(errorMessage: string): string | undefined {
|
|
24
36
|
const match = errorMessage.match(/Mcp-Auth-Server:\s*([^;\]\s]+)/i);
|
|
25
37
|
if (!match?.[1]) return undefined;
|
|
@@ -194,17 +206,11 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
194
206
|
return { requiresAuth: false };
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// Try to extract OAuth endpoints
|
|
200
|
-
const oauth = extractOAuthEndpoints(error);
|
|
201
|
-
|
|
202
|
-
if (oauth) {
|
|
209
|
+
// Error text is useful for classification, but is not trusted discovery authority.
|
|
210
|
+
if (extractOAuthEndpoints(error) || extractMcpAuthServerUrl(error)) {
|
|
203
211
|
return {
|
|
204
212
|
requiresAuth: true,
|
|
205
213
|
authType: "oauth",
|
|
206
|
-
oauth,
|
|
207
|
-
authServerUrl,
|
|
208
214
|
message: "Server requires OAuth authentication. Launching authorization flow...",
|
|
209
215
|
};
|
|
210
216
|
}
|
|
@@ -220,7 +226,6 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
220
226
|
return {
|
|
221
227
|
requiresAuth: true,
|
|
222
228
|
authType: "apikey",
|
|
223
|
-
authServerUrl,
|
|
224
229
|
message: "Server requires API key authentication.",
|
|
225
230
|
};
|
|
226
231
|
}
|
|
@@ -229,7 +234,6 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
229
234
|
return {
|
|
230
235
|
requiresAuth: true,
|
|
231
236
|
authType: "unknown",
|
|
232
|
-
authServerUrl,
|
|
233
237
|
message: "Server requires authentication but type could not be determined.",
|
|
234
238
|
};
|
|
235
239
|
}
|
|
@@ -241,6 +245,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
241
245
|
export async function discoverOAuthEndpoints(
|
|
242
246
|
serverUrl: string,
|
|
243
247
|
authServerUrl?: string,
|
|
248
|
+
options: OAuthDiscoveryOptions = {},
|
|
244
249
|
): Promise<OAuthEndpoints | null> {
|
|
245
250
|
const wellKnownPaths = [
|
|
246
251
|
"/.well-known/oauth-authorization-server",
|
|
@@ -250,8 +255,82 @@ export async function discoverOAuthEndpoints(
|
|
|
250
255
|
"/.mcp/auth",
|
|
251
256
|
"/authorize", // Some MCP servers expose OAuth config here
|
|
252
257
|
];
|
|
253
|
-
const
|
|
254
|
-
const
|
|
258
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
259
|
+
const maxRequests = options.maxRequests ?? 32;
|
|
260
|
+
const maxNodes = options.maxNodes ?? 8;
|
|
261
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
262
|
+
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 10_000);
|
|
263
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
264
|
+
const { promise: aborted, resolve: resolveAbort } = Promise.withResolvers<null>();
|
|
265
|
+
if (signal.aborted) resolveAbort(null);
|
|
266
|
+
else signal.addEventListener("abort", () => resolveAbort(null), { once: true });
|
|
267
|
+
const queue: string[] = [];
|
|
268
|
+
const queued = new Set<string>();
|
|
269
|
+
const visited = new Set<string>();
|
|
270
|
+
let requests = 0;
|
|
271
|
+
|
|
272
|
+
const canonicalUrl = (raw: string): string | null => {
|
|
273
|
+
try {
|
|
274
|
+
const url = new URL(raw);
|
|
275
|
+
url.hash = "";
|
|
276
|
+
return url.toString();
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const validateUrl = async (raw: string): Promise<PublicUrlAccepted | null> => {
|
|
283
|
+
const canonical = canonicalUrl(raw);
|
|
284
|
+
if (!canonical || signal.aborted) return null;
|
|
285
|
+
const result = await Promise.race([validatePublicHttpUrl(canonical, { resolver: options.resolver }), aborted]);
|
|
286
|
+
if (!result) return null;
|
|
287
|
+
return result.ok && !signal.aborted ? result : null;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const enqueue = async (raw: string): Promise<void> => {
|
|
291
|
+
const canonical = canonicalUrl(raw);
|
|
292
|
+
if (!canonical) return;
|
|
293
|
+
const authority = new URL(canonical).origin;
|
|
294
|
+
if (queued.has(authority) || visited.has(authority) || queued.size + visited.size >= maxNodes) return;
|
|
295
|
+
const accepted = await validateUrl(canonical);
|
|
296
|
+
if (!accepted) return;
|
|
297
|
+
queued.add(accepted.url.origin);
|
|
298
|
+
queue.push(accepted.url.origin);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const fetchMetadata = async (raw: string): Promise<Record<string, unknown> | null> => {
|
|
302
|
+
let current = raw;
|
|
303
|
+
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
304
|
+
if (signal.aborted || requests >= maxRequests) return null;
|
|
305
|
+
const accepted = await validateUrl(current);
|
|
306
|
+
if (!accepted) return null;
|
|
307
|
+
const target = new URL(accepted.url);
|
|
308
|
+
const address = accepted.addresses[0];
|
|
309
|
+
target.hostname = address.includes(":") ? `[${address}]` : address;
|
|
310
|
+
requests++;
|
|
311
|
+
const response = await fetcher(target, {
|
|
312
|
+
method: "GET",
|
|
313
|
+
headers: { Accept: "application/json", Host: accepted.url.host },
|
|
314
|
+
redirect: "manual",
|
|
315
|
+
signal,
|
|
316
|
+
tls: accepted.url.protocol === "https:" ? { serverName: accepted.url.hostname } : undefined,
|
|
317
|
+
});
|
|
318
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
319
|
+
const location = response.headers.get("Location");
|
|
320
|
+
if (!location || redirects === maxRedirects) return null;
|
|
321
|
+
current = new URL(location, accepted.url).toString();
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!response.ok) return null;
|
|
325
|
+
return (await Promise.race([response.json(), aborted])) as Record<string, unknown> | null;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const server = await validateUrl(serverUrl);
|
|
331
|
+
if (!server) return null;
|
|
332
|
+
await enqueue(server.url.toString());
|
|
333
|
+
if (authServerUrl) await enqueue(authServerUrl);
|
|
255
334
|
|
|
256
335
|
const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
|
|
257
336
|
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
@@ -310,32 +389,43 @@ export async function discoverOAuthEndpoints(
|
|
|
310
389
|
return null;
|
|
311
390
|
};
|
|
312
391
|
|
|
313
|
-
|
|
314
|
-
|
|
392
|
+
while (queue.length > 0 && !signal.aborted && requests < maxRequests) {
|
|
393
|
+
const baseUrl = queue.shift()!;
|
|
394
|
+
queued.delete(baseUrl);
|
|
395
|
+
if (visited.has(baseUrl)) continue;
|
|
396
|
+
visited.add(baseUrl);
|
|
315
397
|
for (const path of wellKnownPaths) {
|
|
316
398
|
try {
|
|
317
399
|
const url = new URL(path, baseUrl);
|
|
318
|
-
const
|
|
319
|
-
method: "GET",
|
|
320
|
-
headers: { Accept: "application/json" },
|
|
321
|
-
});
|
|
400
|
+
const metadata = await fetchMetadata(url.toString());
|
|
322
401
|
|
|
323
|
-
if (
|
|
324
|
-
const metadata = (await response.json()) as Record<string, unknown>;
|
|
402
|
+
if (metadata) {
|
|
325
403
|
const endpoints = findEndpoints(metadata);
|
|
326
|
-
if (endpoints)
|
|
404
|
+
if (endpoints) {
|
|
405
|
+
const issuer = typeof metadata.issuer === "string" ? canonicalUrl(metadata.issuer) : null;
|
|
406
|
+
const issuerBound = issuer === canonicalUrl(baseUrl);
|
|
407
|
+
const authorization = await validateUrl(endpoints.authorizationUrl);
|
|
408
|
+
const token = await validateUrl(endpoints.tokenUrl);
|
|
409
|
+
const legacySameOrigin =
|
|
410
|
+
!issuer && authorization?.url.origin === baseUrl && token?.url.origin === baseUrl;
|
|
411
|
+
if ((issuerBound || legacySameOrigin) && authorization && token) {
|
|
412
|
+
return {
|
|
413
|
+
...endpoints,
|
|
414
|
+
authorizationUrl: authorization.url.toString(),
|
|
415
|
+
tokenUrl: token.url.toString(),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
327
419
|
|
|
328
420
|
if (path === "/.well-known/oauth-protected-resource") {
|
|
421
|
+
const resource = typeof metadata.resource === "string" ? canonicalUrl(metadata.resource) : null;
|
|
422
|
+
if (resource !== canonicalUrl(server.url.toString())) continue;
|
|
329
423
|
const authServers = Array.isArray(metadata.authorization_servers)
|
|
330
424
|
? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
|
|
331
425
|
: [];
|
|
332
426
|
|
|
333
427
|
for (const discoveredAuthServer of authServers) {
|
|
334
|
-
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
|
|
338
|
-
if (discovered) return discovered;
|
|
428
|
+
await enqueue(discoveredAuthServer);
|
|
339
429
|
}
|
|
340
430
|
}
|
|
341
431
|
}
|
|
@@ -192,7 +192,14 @@ function alive(pid: number): boolean {
|
|
|
192
192
|
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
interface SessionIndexOpenGroup {
|
|
197
|
+
promise: Promise<void>;
|
|
198
|
+
closed: boolean;
|
|
199
|
+
}
|
|
195
200
|
export class SessionIndex {
|
|
201
|
+
static #operations = new Map<string, Promise<void>>();
|
|
202
|
+
static #openGroups = new Map<string, SessionIndexOpenGroup>();
|
|
196
203
|
#agentDir: string;
|
|
197
204
|
#events: SessionIndexEvent[] = [];
|
|
198
205
|
#warnings: string[] = [];
|
|
@@ -201,13 +208,45 @@ export class SessionIndex {
|
|
|
201
208
|
constructor(agentDir: string) {
|
|
202
209
|
this.#agentDir = agentDir;
|
|
203
210
|
}
|
|
211
|
+
static #enqueue<T>(indexPath: string, operation: () => Promise<T>): Promise<T> {
|
|
212
|
+
const previous = SessionIndex.#operations.get(indexPath) ?? Promise.resolve();
|
|
213
|
+
const promise = previous.catch(() => {}).then(operation);
|
|
214
|
+
const completion = promise.then(
|
|
215
|
+
() => {},
|
|
216
|
+
() => {},
|
|
217
|
+
);
|
|
218
|
+
SessionIndex.#operations.set(indexPath, completion);
|
|
219
|
+
void completion.then(() => {
|
|
220
|
+
if (SessionIndex.#operations.get(indexPath) === completion) SessionIndex.#operations.delete(indexPath);
|
|
221
|
+
});
|
|
222
|
+
return promise;
|
|
223
|
+
}
|
|
204
224
|
async open(): Promise<this> {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
226
|
+
let group = SessionIndex.#openGroups.get(indexPath);
|
|
227
|
+
if (!group || group.closed) {
|
|
228
|
+
group = { promise: Promise.resolve(), closed: false };
|
|
229
|
+
SessionIndex.#openGroups.set(indexPath, group);
|
|
230
|
+
group.promise = SessionIndex.#enqueue(indexPath, () => this.#prepareOpenGroup(indexPath, group!));
|
|
231
|
+
}
|
|
232
|
+
await group.promise;
|
|
233
|
+
await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
|
|
208
234
|
return this;
|
|
209
235
|
}
|
|
236
|
+
async #prepareOpenGroup(indexPath: string, group: SessionIndexOpenGroup): Promise<void> {
|
|
237
|
+
try {
|
|
238
|
+
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
239
|
+
await fs.chmod(dirFor(this.#agentDir), 0o700);
|
|
240
|
+
} finally {
|
|
241
|
+
group.closed = true;
|
|
242
|
+
if (SessionIndex.#openGroups.get(indexPath) === group) SessionIndex.#openGroups.delete(indexPath);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
210
245
|
async replay(): Promise<void> {
|
|
246
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
247
|
+
await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
|
|
248
|
+
}
|
|
249
|
+
async #replayUnderLock(): Promise<void> {
|
|
211
250
|
const scan = await this.#scan();
|
|
212
251
|
if (scan.diagnosis.status === "unsupported") throw scan.unsupportedError!;
|
|
213
252
|
this.#events = [...scan.snapshotEvents, ...scan.validLogEvents];
|
|
@@ -355,56 +394,63 @@ export class SessionIndex {
|
|
|
355
394
|
};
|
|
356
395
|
}
|
|
357
396
|
async diagnose(): Promise<SessionIndexDiagnosis> {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
397
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
398
|
+
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
399
|
+
const exists = await Promise.all(
|
|
400
|
+
[snapshotFor(this.#agentDir), logFor(this.#agentDir)].map(async file => {
|
|
401
|
+
try {
|
|
402
|
+
await fs.stat(file);
|
|
403
|
+
return true;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
if (!exists.some(Boolean)) return { status: "healthy", validPrefixSeq: 0, snapshotSeq: 0 };
|
|
411
|
+
return await withFileLock(logFor(this.#agentDir), async () => (await this.#scan()).diagnosis);
|
|
412
|
+
});
|
|
371
413
|
}
|
|
372
414
|
async repair(): Promise<SessionIndexRepairResult> {
|
|
373
|
-
|
|
374
|
-
return await
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
await
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
415
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
416
|
+
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
417
|
+
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
418
|
+
return await withFileLock(logFor(this.#agentDir), async () => {
|
|
419
|
+
const scan = await this.#scan();
|
|
420
|
+
if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
|
|
421
|
+
if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
|
|
422
|
+
const quarantineBase = path.join(dirFor(this.#agentDir), "quarantine");
|
|
423
|
+
await fs.mkdir(quarantineBase, { recursive: true, mode: 0o700 });
|
|
424
|
+
await syncDirectory(quarantineBase);
|
|
425
|
+
const quarantinePath = path.join(quarantineBase, `repair-${Date.now()}-${process.pid}-${randomUUID()}`);
|
|
426
|
+
await fs.mkdir(quarantinePath, { mode: 0o700 });
|
|
427
|
+
await syncDirectory(quarantinePath);
|
|
428
|
+
if (scan.snapshotContents)
|
|
429
|
+
await writeAndSync(path.join(quarantinePath, "index.snapshot.json"), scan.snapshotContents);
|
|
430
|
+
if (scan.logContents) await writeAndSync(path.join(quarantinePath, "index.jsonl"), scan.logContents);
|
|
431
|
+
await syncDirectory(path.join(quarantinePath, "index.jsonl"));
|
|
432
|
+
const events = [...scan.snapshotEvents, ...scan.validLogEvents];
|
|
433
|
+
const snapshot = JSON.stringify({
|
|
434
|
+
version: SESSION_INDEX_SNAPSHOT_VERSION,
|
|
435
|
+
indexSeq: scan.diagnosis.validPrefixSeq,
|
|
436
|
+
events,
|
|
437
|
+
});
|
|
438
|
+
const log = scan.validLogEvents.map(event => JSON.stringify(event)).join("\n");
|
|
439
|
+
await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
|
|
440
|
+
await replaceAtomically(logFor(this.#agentDir), log ? `${log}\n` : "");
|
|
441
|
+
await this.#replayUnderLock();
|
|
442
|
+
return { ...scan.diagnosis, repaired: true, quarantinePath };
|
|
392
443
|
});
|
|
393
|
-
const log = scan.validLogEvents.map(event => JSON.stringify(event)).join("\n");
|
|
394
|
-
await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
|
|
395
|
-
await replaceAtomically(logFor(this.#agentDir), log ? `${log}\n` : "");
|
|
396
|
-
await this.replay();
|
|
397
|
-
return { ...scan.diagnosis, repaired: true, quarantinePath };
|
|
398
444
|
});
|
|
399
445
|
}
|
|
400
|
-
async #
|
|
446
|
+
async #tailUnderLock(snapshotSeq = this.indexSeq, allowResync = true): Promise<void> {
|
|
401
447
|
let data: Buffer;
|
|
402
448
|
try {
|
|
403
449
|
const handle = await fs.open(logFor(this.#agentDir), "r");
|
|
404
450
|
try {
|
|
405
451
|
const stat = await handle.stat();
|
|
406
452
|
if (stat.size < this.#logOffset) {
|
|
407
|
-
if (allowResync) await this
|
|
453
|
+
if (allowResync) await this.#replayUnderLock();
|
|
408
454
|
else this.#warn("Session index log was truncated");
|
|
409
455
|
return;
|
|
410
456
|
}
|
|
@@ -442,7 +488,7 @@ export class SessionIndex {
|
|
|
442
488
|
if (corrupt) {
|
|
443
489
|
this.#corruptSuffix = true;
|
|
444
490
|
this.#warn("Corrupt session index entry; replay truncated");
|
|
445
|
-
if (allowResync) await this
|
|
491
|
+
if (allowResync) await this.#replayUnderLock();
|
|
446
492
|
}
|
|
447
493
|
}
|
|
448
494
|
#warn(message: string): void {
|
|
@@ -450,7 +496,13 @@ export class SessionIndex {
|
|
|
450
496
|
}
|
|
451
497
|
|
|
452
498
|
async refresh(): Promise<void> {
|
|
453
|
-
|
|
499
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
500
|
+
await SessionIndex.#enqueue(indexPath, () =>
|
|
501
|
+
withFileLock(logFor(this.#agentDir), () => this.#refreshUnderLock()),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
async #refreshUnderLock(): Promise<void> {
|
|
505
|
+
await this.#tailUnderLock();
|
|
454
506
|
}
|
|
455
507
|
get indexSeq(): number {
|
|
456
508
|
return this.#events.at(-1)?.indexSeq ?? 0;
|
|
@@ -459,31 +511,37 @@ export class SessionIndex {
|
|
|
459
511
|
input: Omit<SessionIndexEvent, "version" | "indexSeq" | "checksum" | "ts"> &
|
|
460
512
|
Partial<Pick<SessionIndexEvent, "ts">>,
|
|
461
513
|
): Promise<SessionIndexEvent> {
|
|
462
|
-
|
|
463
|
-
return
|
|
464
|
-
await
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
514
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
515
|
+
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
516
|
+
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
517
|
+
return await withFileLock(logFor(this.#agentDir), async () => {
|
|
518
|
+
await this.#replayUnderLock();
|
|
519
|
+
if (this.#corruptSuffix)
|
|
520
|
+
throw new Error(
|
|
521
|
+
"Cannot append to corrupt session index log; run `gjc gc --repair-session-index` to quarantine evidence and retain the valid prefix",
|
|
522
|
+
);
|
|
523
|
+
const unsigned: Omit<SessionIndexEvent, "checksum"> = {
|
|
524
|
+
...input,
|
|
525
|
+
version: SDK_STATE_VERSION,
|
|
526
|
+
indexSeq: this.indexSeq + 1,
|
|
527
|
+
ts: input.ts ?? Date.now(),
|
|
528
|
+
};
|
|
529
|
+
const event: SessionIndexEvent = { ...unsigned, checksum: sessionIndexChecksum(unsigned) };
|
|
530
|
+
await appendSync(logFor(this.#agentDir), JSON.stringify(event));
|
|
531
|
+
await this.#refreshUnderLock();
|
|
532
|
+
if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
|
|
533
|
+
return event;
|
|
534
|
+
});
|
|
480
535
|
});
|
|
481
536
|
}
|
|
482
537
|
async snapshot(): Promise<void> {
|
|
483
|
-
|
|
538
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
539
|
+
await SessionIndex.#enqueue(indexPath, () =>
|
|
540
|
+
withFileLock(logFor(this.#agentDir), () => this.#snapshotUnderLock()),
|
|
541
|
+
);
|
|
484
542
|
}
|
|
485
543
|
async #snapshotUnderLock(): Promise<void> {
|
|
486
|
-
await this
|
|
544
|
+
await this.#replayUnderLock();
|
|
487
545
|
const file = snapshotFor(this.#agentDir);
|
|
488
546
|
let current: unknown;
|
|
489
547
|
try {
|
|
@@ -26,11 +26,13 @@ export interface ChatDaemonRuntimeHandle {
|
|
|
26
26
|
export interface RunChatDaemonInternalDeps {
|
|
27
27
|
processPid?: number;
|
|
28
28
|
pidAlive?: (pid: number) => boolean;
|
|
29
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
29
30
|
createRuntime?: (input: {
|
|
30
31
|
kind: ChatDaemonKind;
|
|
31
32
|
agentDir: string;
|
|
32
33
|
config: ChatDaemonConfig;
|
|
33
34
|
}) => Promise<ChatDaemonRuntimeHandle> | ChatDaemonRuntimeHandle;
|
|
35
|
+
renewHeartbeat?: (input: Parameters<typeof renewChatDaemonHeartbeat>[0]) => Promise<boolean>;
|
|
34
36
|
setInterval?: typeof setInterval;
|
|
35
37
|
clearInterval?: typeof clearInterval;
|
|
36
38
|
}
|
|
@@ -65,8 +67,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
|
|
|
65
67
|
idleTimeoutMs: 60_000,
|
|
66
68
|
rich: { enabled: true },
|
|
67
69
|
richDraft: { enabled: false },
|
|
70
|
+
toolActivity: { enabled: true },
|
|
68
71
|
topics: { nameTemplate: undefined },
|
|
69
72
|
btw: { enabled: true },
|
|
73
|
+
streaming: { enabled: true },
|
|
70
74
|
})
|
|
71
75
|
) {
|
|
72
76
|
throw new Error("Discord notifications are enabled but configuration is incomplete");
|
|
@@ -98,8 +102,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
|
|
|
98
102
|
idleTimeoutMs: 60_000,
|
|
99
103
|
rich: { enabled: true },
|
|
100
104
|
richDraft: { enabled: false },
|
|
105
|
+
toolActivity: { enabled: true },
|
|
101
106
|
topics: { nameTemplate: undefined },
|
|
102
107
|
btw: { enabled: true },
|
|
108
|
+
streaming: { enabled: true },
|
|
103
109
|
})
|
|
104
110
|
) {
|
|
105
111
|
throw new Error("Slack notifications are enabled but configuration is incomplete");
|
|
@@ -160,10 +166,19 @@ export async function runChatDaemonInternal(
|
|
|
160
166
|
if (!ownerId) throw new Error("missing --owner-id");
|
|
161
167
|
const pid = ownerPid(ownerId);
|
|
162
168
|
if (pid !== undefined && !(deps.pidAlive ?? defaultPidAlive)(pid)) return;
|
|
169
|
+
const daemonPid = deps.processPid ?? process.pid;
|
|
163
170
|
const config = await loadConfig(agentDir, kind);
|
|
164
171
|
if (!config) return;
|
|
165
172
|
if (
|
|
166
|
-
!(await acquireChatDaemonOwnership({
|
|
173
|
+
!(await acquireChatDaemonOwnership({
|
|
174
|
+
agentDir,
|
|
175
|
+
kind,
|
|
176
|
+
ownerId,
|
|
177
|
+
pid: daemonPid,
|
|
178
|
+
identity: config.identity,
|
|
179
|
+
pidAlive: deps.pidAlive,
|
|
180
|
+
pidIncarnation: deps.pidIncarnation,
|
|
181
|
+
}))
|
|
167
182
|
)
|
|
168
183
|
return;
|
|
169
184
|
|
|
@@ -171,30 +186,60 @@ export async function runChatDaemonInternal(
|
|
|
171
186
|
let runtime: ChatDaemonRuntimeHandle | undefined;
|
|
172
187
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
173
188
|
let stopping = false;
|
|
189
|
+
let terminalError: unknown;
|
|
190
|
+
let runtimeStop: Promise<void> | undefined;
|
|
191
|
+
const stopRuntime = (): Promise<void> => {
|
|
192
|
+
runtimeStop ??= runtime?.stop() ?? Promise.resolve();
|
|
193
|
+
return runtimeStop;
|
|
194
|
+
};
|
|
174
195
|
const stop = (): void => {
|
|
175
196
|
stopping = true;
|
|
197
|
+
void stopRuntime().catch(error => {
|
|
198
|
+
terminalError ??= error;
|
|
199
|
+
});
|
|
176
200
|
};
|
|
177
201
|
try {
|
|
178
202
|
incarnation = (await readChatDaemonState(agentDir, kind))?.incarnation;
|
|
179
203
|
if (!incarnation) throw new Error("chat daemon ownership state is missing an incarnation");
|
|
180
204
|
runtime = await (deps.createRuntime?.({ kind, agentDir, config }) ?? defaultRuntime({ kind, agentDir, config }));
|
|
181
|
-
const
|
|
182
|
-
|
|
205
|
+
const activeRuntime = runtime;
|
|
206
|
+
const renewHeartbeat = async (): Promise<boolean> =>
|
|
207
|
+
await (deps.renewHeartbeat ?? renewChatDaemonHeartbeat)({
|
|
183
208
|
agentDir,
|
|
184
209
|
kind,
|
|
185
210
|
ownerId,
|
|
186
|
-
pid:
|
|
211
|
+
pid: daemonPid,
|
|
187
212
|
incarnation,
|
|
188
|
-
transportHealthy:
|
|
213
|
+
transportHealthy: activeRuntime.transportHealthy?.() ?? true,
|
|
214
|
+
pidAlive: deps.pidAlive,
|
|
215
|
+
pidIncarnation: deps.pidIncarnation,
|
|
189
216
|
});
|
|
217
|
+
const terminateForLostOwnership = async (): Promise<void> => {
|
|
218
|
+
stopping = true;
|
|
219
|
+
await stopRuntime();
|
|
190
220
|
};
|
|
191
221
|
process.once("SIGTERM", stop);
|
|
192
222
|
process.once("SIGINT", stop);
|
|
223
|
+
if (!(await renewHeartbeat())) {
|
|
224
|
+
await terminateForLostOwnership();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
await runtime.start();
|
|
193
228
|
interval = (deps.setInterval ?? setInterval)(() => {
|
|
194
|
-
void
|
|
229
|
+
void (async () => {
|
|
230
|
+
try {
|
|
231
|
+
if (!(await renewHeartbeat())) await terminateForLostOwnership();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
terminalError ??= error;
|
|
234
|
+
stopping = true;
|
|
235
|
+
try {
|
|
236
|
+
await stopRuntime();
|
|
237
|
+
} catch (stopError) {
|
|
238
|
+
terminalError ??= stopError;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
})();
|
|
195
242
|
}, 5_000);
|
|
196
|
-
await runtime.start();
|
|
197
|
-
await renewHeartbeat();
|
|
198
243
|
while (!stopping) {
|
|
199
244
|
const request = await readChatDaemonControlRequest(agentDir, kind);
|
|
200
245
|
if (request?.ownerId === ownerId && request.incarnation === incarnation) {
|
|
@@ -208,9 +253,26 @@ export async function runChatDaemonInternal(
|
|
|
208
253
|
process.off("SIGTERM", stop);
|
|
209
254
|
process.off("SIGINT", stop);
|
|
210
255
|
try {
|
|
211
|
-
await
|
|
256
|
+
await stopRuntime();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
terminalError ??= error;
|
|
212
259
|
} finally {
|
|
213
|
-
|
|
260
|
+
if (incarnation !== undefined) {
|
|
261
|
+
try {
|
|
262
|
+
await releaseChatDaemonOwnership({
|
|
263
|
+
agentDir,
|
|
264
|
+
kind,
|
|
265
|
+
ownerId,
|
|
266
|
+
pid: daemonPid,
|
|
267
|
+
incarnation,
|
|
268
|
+
pidAlive: deps.pidAlive,
|
|
269
|
+
pidIncarnation: deps.pidIncarnation,
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
terminalError ??= error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
214
275
|
}
|
|
215
276
|
}
|
|
277
|
+
if (terminalError !== undefined) throw terminalError;
|
|
216
278
|
}
|