@gajae-code/coding-agent 0.11.6 → 0.11.7

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 (131) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +56 -6
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +25 -4
  40. package/dist/types/setup/credential-import.d.ts +5 -1
  41. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  42. package/dist/types/ssh/utils.d.ts +1 -0
  43. package/dist/types/task/discovery.d.ts +3 -1
  44. package/dist/types/tools/fetch.d.ts +5 -8
  45. package/dist/types/tools/image-gen.d.ts +16 -0
  46. package/dist/types/tools/skill-discovery.d.ts +7 -0
  47. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  48. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  49. package/dist/types/web/insane/bridge.d.ts +1 -0
  50. package/dist/types/web/insane/url-guard.d.ts +17 -0
  51. package/package.json +8 -8
  52. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  53. package/src/capability/index.ts +50 -42
  54. package/src/capability/types.ts +3 -0
  55. package/src/cli/setup-cli.ts +10 -4
  56. package/src/cli.ts +34 -9
  57. package/src/commands/daemon.ts +22 -5
  58. package/src/commands/sdk.ts +42 -9
  59. package/src/config/keybindings.ts +1 -1
  60. package/src/config/model-profiles.ts +18 -0
  61. package/src/config/settings-schema.ts +57 -6
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  63. package/src/discovery/ssh.ts +12 -1
  64. package/src/eval/py/kernel.ts +1 -21
  65. package/src/eval/py/runner-artifact.ts +84 -0
  66. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  67. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  68. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  69. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  70. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  71. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  72. package/src/internal-urls/docs-index.generated.ts +8 -7
  73. package/src/internal-urls/local-protocol.ts +32 -6
  74. package/src/main.ts +86 -27
  75. package/src/modes/action-registry.ts +1 -1
  76. package/src/modes/components/btw-panel.ts +82 -16
  77. package/src/modes/components/model-selector.ts +31 -3
  78. package/src/modes/components/tool-status-header.ts +6 -1
  79. package/src/modes/controllers/btw-controller.ts +119 -36
  80. package/src/modes/controllers/input-controller.ts +31 -0
  81. package/src/modes/controllers/selector-controller.ts +106 -12
  82. package/src/modes/interactive-mode.ts +16 -7
  83. package/src/modes/types.ts +1 -0
  84. package/src/prompts/system/btw-user.md +3 -8
  85. package/src/prompts/tools/skill-discovery.md +1 -0
  86. package/src/runtime-mcp/client.ts +41 -55
  87. package/src/runtime-mcp/content-limits.ts +71 -0
  88. package/src/runtime-mcp/json-rpc.ts +23 -9
  89. package/src/runtime-mcp/transports/http.ts +50 -16
  90. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  91. package/src/sdk/bus/index.ts +4 -5
  92. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  93. package/src/sdk/bus/notification-service.ts +41 -18
  94. package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
  95. package/src/sdk/bus/telegram-daemon-control.ts +2 -2
  96. package/src/sdk/bus/telegram-daemon.ts +1663 -375
  97. package/src/sdk/bus/topic-registry.ts +451 -12
  98. package/src/sdk/client/discovery.ts +19 -3
  99. package/src/sdk/client/liveness.ts +49 -0
  100. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  101. package/src/sdk/session.ts +29 -5
  102. package/src/sdk/transport/auth-preface.ts +82 -0
  103. package/src/sdk/transport/index.ts +18 -0
  104. package/src/sdk/transport/relay.ts +252 -0
  105. package/src/sdk/transport/serve-cli.ts +105 -0
  106. package/src/sdk/transport/socket.ts +197 -0
  107. package/src/sdk/transport/stdio.ts +17 -0
  108. package/src/session/agent-session.ts +240 -149
  109. package/src/session/blob-store.ts +19 -6
  110. package/src/session/btw-contract.ts +59 -0
  111. package/src/session/internal/managed-session-scope.ts +313 -57
  112. package/src/session/internal/managed-session-storage.ts +146 -35
  113. package/src/session/session-manager.ts +319 -85
  114. package/src/setup/credential-import.ts +28 -3
  115. package/src/setup/provider-onboarding.ts +30 -1
  116. package/src/setup/provider-presets.json +2 -1
  117. package/src/slash-commands/builtin-registry.ts +1 -1
  118. package/src/ssh/utils.ts +14 -0
  119. package/src/task/discovery.ts +8 -2
  120. package/src/tools/ask.ts +1 -1
  121. package/src/tools/fetch.ts +13 -131
  122. package/src/tools/image-gen.ts +301 -20
  123. package/src/tools/read.ts +2 -0
  124. package/src/tools/skill-discovery.ts +39 -3
  125. package/src/tools/sqlite-reader.ts +63 -5
  126. package/src/utils/pasted-image-path.ts +15 -6
  127. package/src/utils/shell-snapshot.ts +173 -63
  128. package/src/web/insane/bridge.ts +5 -1
  129. package/src/web/insane/url-guard.ts +138 -27
  130. package/src/web/scrapers/types.ts +19 -49
  131. package/src/web/scrapers/utils.ts +12 -26
@@ -0,0 +1,71 @@
1
+ export const MCP_MAX_CONTENT_BYTES = 16 * 1024 * 1024;
2
+ export const MCP_MAX_ERROR_BYTES = 16 * 1024;
3
+ export const MCP_MAX_SSE_REQUEST_MESSAGES = 1024;
4
+ export const MCP_MAX_SSE_BATCH_MESSAGES = 1024;
5
+ export const MCP_HTTP_TIMEOUT_MS = 30_000;
6
+
7
+ function ignoreCancellation(cancel: () => Promise<unknown>): void {
8
+ try {
9
+ void cancel().catch(() => {});
10
+ } catch {}
11
+ }
12
+
13
+ export function cancelMCPStream(stream: ReadableStream<Uint8Array> | null): void {
14
+ if (stream) ignoreCancellation(() => stream.cancel());
15
+ }
16
+
17
+ type ByteReader = Pick<ReadableStreamDefaultReader<Uint8Array>, "read">;
18
+
19
+ async function readChunk(reader: ByteReader, signal?: AbortSignal) {
20
+ if (!signal) return reader.read();
21
+ signal.throwIfAborted();
22
+ const aborted = Promise.withResolvers<never>();
23
+ const onAbort = () => aborted.reject(signal.reason);
24
+ signal.addEventListener("abort", onAbort, { once: true });
25
+ try {
26
+ return await Promise.race([reader.read(), aborted.promise]);
27
+ } finally {
28
+ signal.removeEventListener("abort", onAbort);
29
+ }
30
+ }
31
+
32
+ export async function readMCPResponseText(response: Response, limit: number, truncate = false, signal?: AbortSignal) {
33
+ const declared = response.headers.get("Content-Length");
34
+ if (!truncate && declared && /^\d+$/.test(declared) && Number(declared) > limit) {
35
+ cancelMCPStream(response.body);
36
+ throw new Error("MCP response exceeds size limit");
37
+ }
38
+ if (!response.body) return "";
39
+ const reader = response.body.getReader();
40
+ let buffer = new Uint8Array(Math.min(limit, 64 * 1024));
41
+ let total = 0;
42
+ try {
43
+ while (true) {
44
+ const { value, done } = await readChunk(reader, signal);
45
+ if (done) break;
46
+ if (value.length === 0) continue;
47
+ const remaining = limit - total;
48
+ if (value.length > remaining) {
49
+ if (truncate && remaining > 0) buffer.set(value.subarray(0, remaining), total);
50
+ ignoreCancellation(() => reader.cancel());
51
+ if (!truncate) throw new Error("MCP response exceeds size limit");
52
+ return `${new TextDecoder().decode(buffer)}\u2026`;
53
+ }
54
+ if (total + value.length > buffer.length) {
55
+ const grown = new Uint8Array(Math.min(limit, Math.max(total + value.length, buffer.length * 2)));
56
+ grown.set(buffer);
57
+ buffer = grown;
58
+ }
59
+ buffer.set(value, total);
60
+ total += value.length;
61
+ }
62
+ return new TextDecoder().decode(buffer.subarray(0, total));
63
+ } catch (error) {
64
+ ignoreCancellation(() => reader.cancel());
65
+ throw error;
66
+ } finally {
67
+ try {
68
+ reader.releaseLock();
69
+ } catch {}
70
+ }
71
+ }
@@ -4,6 +4,8 @@
4
4
  * Lightweight utilities for calling MCP servers directly via HTTP
5
5
  * without maintaining persistent connections.
6
6
  */
7
+ // biome-ignore assist/source/organizeImports: Keep independent MCP security imports on separate merge anchors.
8
+ import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
7
9
  import { logger } from "@gajae-code/utils";
8
10
 
9
11
  /** Parse SSE response format (lines starting with "data: ") */
@@ -25,6 +27,15 @@ export function parseSSE(text: string): unknown {
25
27
  }
26
28
  }
27
29
 
30
+ async function translateMCPTimeout<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
31
+ try {
32
+ return await operation;
33
+ } catch (error) {
34
+ if (signal.aborted && error === signal.reason) throw new Error("MCP request timed out");
35
+ throw error;
36
+ }
37
+ }
38
+
28
39
  /** JSON-RPC 2.0 response structure */
29
40
  export interface JsonRpcResponse<T = unknown> {
30
41
  jsonrpc: "2.0";
@@ -57,22 +68,25 @@ export async function callMCP<T = unknown>(
57
68
  params: params ?? {},
58
69
  };
59
70
 
60
- const response = await fetch(url, {
61
- method: "POST",
62
- headers: {
63
- "Content-Type": "application/json",
64
- Accept: "application/json, text/event-stream",
65
- },
66
- body: JSON.stringify(body),
67
- });
71
+ const signal = AbortSignal.timeout(MCP_HTTP_TIMEOUT_MS);
72
+ const response = await translateMCPTimeout(
73
+ fetch(url, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
76
+ body: JSON.stringify(body),
77
+ signal,
78
+ }),
79
+ signal,
80
+ );
68
81
 
69
82
  if (!response.ok) {
83
+ cancelMCPStream(response.body);
70
84
  const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`;
71
85
  logger.error(errorMsg, { url, method, params });
72
86
  throw new Error(errorMsg);
73
87
  }
74
88
 
75
- const text = await response.text();
89
+ const text = await translateMCPTimeout(readMCPResponseText(response, MCP_MAX_CONTENT_BYTES, false, signal), signal);
76
90
  const result = parseSSE(text) as JsonRpcResponse<T> | null;
77
91
 
78
92
  if (!result) {
@@ -16,6 +16,14 @@ import type {
16
16
  MCPTransport,
17
17
  } from "../../runtime-mcp/types";
18
18
  import { MCPExpectedFailure, toJsonRpcError } from "../../runtime-mcp/types";
19
+ import {
20
+ cancelMCPStream,
21
+ MCP_MAX_CONTENT_BYTES,
22
+ MCP_MAX_ERROR_BYTES,
23
+ MCP_MAX_SSE_BATCH_MESSAGES,
24
+ MCP_MAX_SSE_REQUEST_MESSAGES,
25
+ readMCPResponseText,
26
+ } from "../content-limits";
19
27
 
20
28
  /**
21
29
  * HTTP transport for MCP servers.
@@ -73,6 +81,9 @@ export class HttpTransport implements MCPTransport {
73
81
  if (this.#sseConnection) return;
74
82
 
75
83
  const sseConnection = new AbortController();
84
+ const headerController = new AbortController();
85
+ const headerTimeout = this.config.timeout ?? 30000;
86
+ const headerTimeoutId = setTimeout(() => headerController.abort(), headerTimeout);
76
87
  this.#sseConnection = sseConnection;
77
88
  const headers: Record<string, string> = {
78
89
  Accept: "text/event-stream",
@@ -88,18 +99,22 @@ export class HttpTransport implements MCPTransport {
88
99
  response = await fetch(this.config.url, {
89
100
  method: "GET",
90
101
  headers,
91
- signal: sseConnection.signal,
102
+ signal: AbortSignal.any([sseConnection.signal, headerController.signal]),
92
103
  });
93
104
  } catch (error) {
94
105
  this.#sseConnection = this.#sseConnection === sseConnection ? null : this.#sseConnection;
95
- if (error instanceof Error && error.name !== "AbortError") {
106
+ if (headerController.signal.aborted && !sseConnection.signal.aborted) {
107
+ this.onError?.(new Error(`SSE connection timeout after ${headerTimeout}ms`));
108
+ } else if (error instanceof Error && error.name !== "AbortError") {
96
109
  this.onError?.(error);
97
110
  }
98
111
  return;
112
+ } finally {
113
+ clearTimeout(headerTimeoutId);
99
114
  }
100
115
 
101
116
  if (response.status === 405 || !response.ok || !response.body) {
102
- await response.body?.cancel().catch(() => {});
117
+ cancelMCPStream(response.body);
103
118
  this.#sseConnection = this.#sseConnection === sseConnection ? null : this.#sseConnection;
104
119
  return;
105
120
  }
@@ -117,8 +132,13 @@ export class HttpTransport implements MCPTransport {
117
132
  }
118
133
  async #readSSEStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
119
134
  try {
120
- for await (const message of readSseJson<JsonRpcMessage>(body, signal)) {
135
+ for await (const message of readSseJson<JsonRpcMessage>(body, signal, undefined, {
136
+ maxEventBytes: MCP_MAX_CONTENT_BYTES,
137
+ })) {
121
138
  if (!this.#connected) break;
139
+ if (Array.isArray(message) && message.length > MCP_MAX_SSE_BATCH_MESSAGES) {
140
+ throw new Error("MCP SSE batch exceeds message limit");
141
+ }
122
142
  this.#dispatchSSEMessage(message);
123
143
  }
124
144
  } catch (error) {
@@ -221,7 +241,7 @@ export class HttpTransport implements MCPTransport {
221
241
  }
222
242
 
223
243
  if (!response.ok) {
224
- const text = await response.text();
244
+ const text = await readMCPResponseText(response, MCP_MAX_ERROR_BYTES, true, operationSignal);
225
245
  const wwwAuthenticate = response.headers.get("WWW-Authenticate");
226
246
  const mcpAuthServer = response.headers.get("Mcp-Auth-Server");
227
247
  const authHints = [
@@ -245,7 +265,9 @@ export class HttpTransport implements MCPTransport {
245
265
  if (!response.body) {
246
266
  throw new MCPExpectedFailure();
247
267
  }
248
- const parsedResult = await response.json();
268
+ const parsedResult = JSON.parse(
269
+ await readMCPResponseText(response, MCP_MAX_CONTENT_BYTES, false, operationSignal),
270
+ ) as unknown;
249
271
 
250
272
  if (
251
273
  typeof parsedResult !== "object" ||
@@ -293,6 +315,7 @@ export class HttpTransport implements MCPTransport {
293
315
 
294
316
  const { promise, resolve, reject } = Promise.withResolvers<T>();
295
317
  let captured = false;
318
+ let messageCount = 0;
296
319
 
297
320
  // Drain this per-request SSE response from a single iterator. Once the
298
321
  // matching response arrives, resolve/reject and abort the reader so the
@@ -304,8 +327,20 @@ export class HttpTransport implements MCPTransport {
304
327
  this.#streamControllers.add(drainController);
305
328
  const drain = async (): Promise<void> => {
306
329
  try {
307
- for await (const raw of readSseJson<JsonRpcMessage | JsonRpcMessage[]>(response.body!, operationSignal)) {
330
+ for await (const raw of readSseJson<JsonRpcMessage | JsonRpcMessage[]>(
331
+ response.body!,
332
+ operationSignal,
333
+ undefined,
334
+ {
335
+ maxEventBytes: MCP_MAX_CONTENT_BYTES,
336
+ maxTotalBytes: MCP_MAX_CONTENT_BYTES,
337
+ },
338
+ )) {
308
339
  const messages = Array.isArray(raw) ? raw : [raw];
340
+ if (messages.length > MCP_MAX_SSE_BATCH_MESSAGES) throw new Error("MCP SSE batch exceeds message limit");
341
+ messageCount += messages.length;
342
+ if (messageCount > MCP_MAX_SSE_REQUEST_MESSAGES)
343
+ throw new Error("MCP SSE response exceeds message limit");
309
344
  for (const message of messages) {
310
345
  if (
311
346
  !captured &&
@@ -352,7 +387,7 @@ export class HttpTransport implements MCPTransport {
352
387
  } finally {
353
388
  clearTimeout(timeoutId);
354
389
  this.#streamControllers.delete(drainController);
355
- await response.body?.cancel().catch(() => {});
390
+ cancelMCPStream(response.body);
356
391
  }
357
392
  };
358
393
 
@@ -396,7 +431,7 @@ export class HttpTransport implements MCPTransport {
396
431
  });
397
432
  // Retry once on auth failure if onAuthError is wired
398
433
  if (this.onAuthError && (resp.status === 401 || resp.status === 403)) {
399
- await resp.body?.cancel();
434
+ cancelMCPStream(resp.body);
400
435
  const newHeaders = await this.onAuthError();
401
436
  if (newHeaders) {
402
437
  this.config.headers ??= {};
@@ -408,11 +443,11 @@ export class HttpTransport implements MCPTransport {
408
443
  body: JSON.stringify(body),
409
444
  signal: AbortSignal.timeout(this.config.timeout ?? 30000),
410
445
  });
411
- await retry.body?.cancel();
446
+ cancelMCPStream(retry.body);
412
447
  return;
413
448
  }
414
449
  }
415
- await resp.body?.cancel();
450
+ cancelMCPStream(resp.body);
416
451
  } catch {
417
452
  // Best-effort response delivery — server may have disconnected
418
453
  }
@@ -452,11 +487,9 @@ export class HttpTransport implements MCPTransport {
452
487
  signal: abortController.signal,
453
488
  });
454
489
 
455
- clearTimeout(timeoutId);
456
-
457
490
  // 202 Accepted is success for notifications
458
491
  if (!response.ok && response.status !== 202) {
459
- const text = await response.text();
492
+ const text = await readMCPResponseText(response, MCP_MAX_ERROR_BYTES, true, abortController.signal);
460
493
  throw new Error(`HTTP ${response.status}: ${text}`);
461
494
  }
462
495
 
@@ -472,14 +505,15 @@ export class HttpTransport implements MCPTransport {
472
505
  const reader = this.#readSSEStream(response.body, AbortSignal.any(signals));
473
506
  this.#trackReader(reader, streamController);
474
507
  } else {
475
- await response.body?.cancel();
508
+ cancelMCPStream(response.body);
476
509
  }
477
510
  } catch (error) {
478
- clearTimeout(timeoutId);
479
511
  if (error instanceof Error && error.name === "AbortError") {
480
512
  throw new MCPExpectedFailure(new Error(`Notify timeout after ${timeout}ms`));
481
513
  }
482
514
  throw error instanceof MCPExpectedFailure ? error : new MCPExpectedFailure(error);
515
+ } finally {
516
+ clearTimeout(timeoutId);
483
517
  }
484
518
  }
485
519
 
@@ -27,11 +27,12 @@ export type ChatDaemonAction = "stop" | "reload";
27
27
  * Generation 6 carries the retained managed filesystem authority boundary.
28
28
  * Generation 7 restores macOS daemon signaling (kill(2) with a start-time
29
29
  * incarnation recheck) so a live/hung owner can be replaced without an external
30
- * `kill -9`.
30
+ * `kill -9`. Generation 8 adopts Windows expected-identity ACL verification and
31
+ * repair for shared native authority.
31
32
  */
32
33
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
33
- discord: 7,
34
- slack: 7,
34
+ discord: 8,
35
+ slack: 8,
35
36
  };
36
37
 
37
38
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -30,7 +30,7 @@ import { promisify } from "node:util";
30
30
  import { ThinkingLevel } from "@gajae-code/agent-core";
31
31
  import type { ImageContent, TextContent, Tool } from "@gajae-code/ai";
32
32
  import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
33
- import { logger, postmortem, prompt, VERSION } from "@gajae-code/utils";
33
+ import { logger, postmortem, VERSION } from "@gajae-code/utils";
34
34
  import { Settings } from "../../config/settings";
35
35
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
36
36
  import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
@@ -40,7 +40,6 @@ import {
40
40
  type WorkflowGateTerminalController,
41
41
  type WorkflowGateTerminalProof,
42
42
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
43
- import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
44
43
  import type { AgentSessionEvent } from "../../session/agent-session";
45
44
  import { parseThinkingLevel } from "../../thinking";
46
45
  import type {
@@ -2778,7 +2777,7 @@ export function createNotificationsExtension(
2778
2777
  sdkHostModeSupported?: boolean;
2779
2778
 
2780
2779
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2781
- runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2780
+ runBtwTurn?: (question: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2782
2781
  /** Observes settlement of optional session-branch startup after reconciliation completes. */
2783
2782
  onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
2784
2783
  readNotificationFile?: (path: string) => Promise<Buffer>;
@@ -3587,10 +3586,10 @@ export function createNotificationsExtension(
3587
3586
  };
3588
3587
 
3589
3588
  const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3590
- if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3589
+ if (!options.runBtwTurn) throw new Error("Ephemeral turns are unavailable.");
3591
3590
  const generation = initializedRuntime.policyGeneration;
3592
3591
  if (initializedRuntime.policySuspended) throw new Error("Notification policy is provisional.");
3593
- const result = await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3592
+ const result = await options.runBtwTurn(question, signal);
3594
3593
  if (
3595
3594
  initializedRuntime.policySuspended ||
3596
3595
  initializedRuntime.policyGeneration !== generation ||
@@ -238,11 +238,12 @@ function isSafeBranch(value: string): boolean {
238
238
  /**
239
239
  * Map a lifecycle response/error to a user-facing Telegram message (G010).
240
240
  *
241
- * Only derives text from sessionId, mode, reason, a safe message, and candidate
242
- * {sessionId,path} — never a token or prompt. Each error reason gets tailored,
243
- * actionable copy; an "in progress" pending response is surfaced distinctly.
241
+ * Only derives text from sessionId, mode, reason, a safe message, the originating
242
+ * lifecycle verb, and candidate {sessionId,path} — never a token or prompt. Each
243
+ * error reason gets tailored, actionable copy; an "in progress" pending response
244
+ * is surfaced distinctly.
244
245
  */
245
- export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
246
+ export function formatLifecycleOutcome(r: SessionLifecycleResponse, verb?: LifecycleCommandVerb): string {
246
247
  switch (r.type) {
247
248
  case "session_create_response":
248
249
  return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
@@ -282,9 +283,17 @@ export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
282
283
  case "unsupported_platform":
283
284
  return "Remote session lifecycle is unavailable on this psmux host because GJC cannot prove immutable session identity. No lifecycle action was performed. Use a local GJC terminal with a supported tmux provider.";
284
285
  case "terminal_uncertain":
285
- return /in progress/i.test(r.message)
286
- ? "\u23f3 That request is already in progress \u2014 hold on."
287
- : "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying so you don't double-spawn.";
286
+ if (/in progress/i.test(r.message)) return "\u23f3 That request is already in progress \u2014 hold on.";
287
+ switch (verb) {
288
+ case "session_create":
289
+ return "\u26a0\ufe0f Create outcome uncertain. The session may already be starting \u2014 check /session_recent before retrying to avoid starting it twice.";
290
+ case "session_close":
291
+ return "\u26a0\ufe0f Close outcome uncertain. The session may already be closed \u2014 check /session_recent before retrying.";
292
+ case "session_resume":
293
+ return "\u26a0\ufe0f Resume outcome uncertain. The session may already be reattached or restarting \u2014 check /session_recent before retrying.";
294
+ default:
295
+ return "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying.";
296
+ }
288
297
  default:
289
298
  return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
290
299
  }
@@ -251,19 +251,29 @@ export function formatNotificationStatusReport(report: NotificationStatusReport)
251
251
 
252
252
  // --- endpoint / daemon file readers -------------------------------------
253
253
 
254
- interface EndpointView {
254
+ export interface NotificationEndpointView {
255
255
  sessionId: string;
256
256
  pid: number | undefined;
257
257
  stale: boolean;
258
258
  }
259
259
 
260
- type EndpointFileRead =
261
- | { kind: "endpoint"; view: EndpointView; identity: NotificationEndpointFileIdentity }
260
+ export type NotificationEndpointLiveness = "live" | "dead" | "unknown";
261
+
262
+ /**
263
+ * Classification used by recovery and startup takeover. A file is an endpoint
264
+ * only when it has endpoint authority fields; lifecycle/audit records are never
265
+ * candidates for endpoint cleanup.
266
+ */
267
+ export type NotificationEndpointClassification =
268
+ | {
269
+ kind: "endpoint";
270
+ view: NotificationEndpointView;
271
+ liveness: NotificationEndpointLiveness;
272
+ identity: NotificationEndpointFileIdentity;
273
+ }
262
274
  | { kind: "non-endpoint" }
263
275
  | { kind: "unreadable" };
264
276
 
265
- type EndpointLiveness = "live" | "dead" | "unknown";
266
-
267
277
  /**
268
278
  * Classify an endpoint using owner-proof semantics. An endpoint is only `dead`
269
279
  * with positive proof: an explicit `stale` tombstone, or a recorded pid that is
@@ -271,7 +281,10 @@ type EndpointLiveness = "live" | "dead" | "unknown";
271
281
  * must never be treated as dead — removing it could delete a live session's
272
282
  * discovery file that simply omitted a pid.
273
283
  */
274
- function endpointLiveness(view: EndpointView, pidAlive: (pid: number) => boolean): EndpointLiveness {
284
+ export function notificationEndpointLiveness(
285
+ view: NotificationEndpointView,
286
+ pidAlive: (pid: number) => boolean,
287
+ ): NotificationEndpointLiveness {
275
288
  if (view.stale) return "dead";
276
289
  if (view.pid === undefined) return "unknown";
277
290
  return pidAlive(view.pid) ? "live" : "dead";
@@ -314,11 +327,19 @@ function isCanonicalLifecycleArtifactName(name: string): boolean {
314
327
  );
315
328
  }
316
329
 
317
- function unreadableEndpointResult(file: string): EndpointFileRead {
330
+ function unreadableEndpointResult(file: string): NotificationEndpointClassification {
318
331
  return isCanonicalLifecycleArtifactName(path.basename(file)) ? { kind: "non-endpoint" } : { kind: "unreadable" };
319
332
  }
320
333
 
321
- async function readEndpointView(fs: NotificationServiceFs, file: string): Promise<EndpointFileRead> {
334
+ /**
335
+ * Read and classify one endpoint candidate. The returned identity belongs to
336
+ * exactly the bytes inspected and is required for any later deletion.
337
+ */
338
+ export async function classifyNotificationEndpoint(
339
+ fs: Pick<NotificationServiceFs, "readEndpointFile">,
340
+ file: string,
341
+ pidAlive: (pid: number) => boolean,
342
+ ): Promise<NotificationEndpointClassification> {
322
343
  let endpoint: NotificationEndpointFile;
323
344
  let raw: string;
324
345
  try {
@@ -334,16 +355,19 @@ async function readEndpointView(fs: NotificationServiceFs, file: string): Promis
334
355
  return unreadableEndpointResult(file);
335
356
  }
336
357
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "non-endpoint" };
358
+ if (path.basename(file) === "broker.json") return { kind: "non-endpoint" };
337
359
  const rec = parsed as Record<string, unknown>;
338
360
  if (isLifecycleArtifact(rec) || typeof rec.url !== "string" || typeof rec.token !== "string")
339
361
  return { kind: "non-endpoint" };
362
+ const view: NotificationEndpointView = {
363
+ sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
364
+ pid: safePositiveInteger(rec.pid),
365
+ stale: rec.stale === true,
366
+ };
340
367
  return {
341
368
  kind: "endpoint",
342
- view: {
343
- sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
344
- pid: safePositiveInteger(rec.pid),
345
- stale: rec.stale === true,
346
- },
369
+ view,
370
+ liveness: notificationEndpointLiveness(view, pidAlive),
347
371
  identity: endpoint.identity,
348
372
  };
349
373
  }
@@ -604,14 +628,13 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
604
628
  let unknownEndpoints = 0;
605
629
  let unreadable = 0;
606
630
  for (const name of files) {
607
- const record = await readEndpointView(fs, path.join(dir, name));
631
+ const record = await classifyNotificationEndpoint(fs, path.join(dir, name), pidAlive);
608
632
  if (record.kind === "non-endpoint") continue;
609
633
  if (record.kind === "unreadable") {
610
634
  unreadable += 1;
611
635
  continue;
612
636
  }
613
- const view = record.view;
614
- switch (endpointLiveness(view, pidAlive)) {
637
+ switch (record.liveness) {
615
638
  case "live":
616
639
  live += 1;
617
640
  break;
@@ -1040,7 +1063,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1040
1063
  let unreadable = 0;
1041
1064
  for (const name of files) {
1042
1065
  const file = path.join(dir, name);
1043
- const record = await readEndpointView(fs, file);
1066
+ const record = await classifyNotificationEndpoint(fs, file, pidAlive);
1044
1067
  if (record.kind === "non-endpoint") continue;
1045
1068
  if (record.kind === "unreadable") {
1046
1069
  // Leave unparseable files untouched: they may be mid-write by a live server.
@@ -1048,7 +1071,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1048
1071
  continue;
1049
1072
  }
1050
1073
  const view = record.view;
1051
- if (endpointLiveness(view, pidAlive) !== "dead") {
1074
+ if (record.liveness !== "dead") {
1052
1075
  // Keep live AND unknown (PID-less) endpoints: only positive proof of
1053
1076
  // death (a stale tombstone or a dead pid) authorizes removal.
1054
1077
  kept += 1;
@@ -21,7 +21,12 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
21
21
  * confirmed-dead owner's lock in generation 10, legacy stopped-tombstone
22
22
  * reclamation in generation 11, force-escalated SIGKILL of an unresponsive
23
23
  * older-generation owner during automatic generation-upgrade reload in
24
- * generation 12, and restored macOS daemon signaling (kill(2) with a start-time
25
- * incarnation recheck, replacing the darwin no-op) in generation 13.
24
+ * generation 12, restored macOS daemon signaling (kill(2) with a start-time
25
+ * incarnation recheck, replacing the darwin no-op) in generation 13, retained
26
+ * legacy stopped-lock reclamation in generation 14, Windows expected-identity
27
+ * ACL verification and repair in generation 15, identity-fenced stale endpoint
28
+ * startup recovery in generation 16, Telegram topic recovery authority fencing
29
+ * in generation 17, and fail-closed blank-token validation plus lifecycle-startup
30
+ * stop fencing in generation 18.
26
31
  */
27
- export const DAEMON_GENERATION = 13;
32
+ export const DAEMON_GENERATION = 18;
@@ -178,8 +178,8 @@ function defaultPidAlive(pid: number): boolean {
178
178
  try {
179
179
  process.kill(pid, 0);
180
180
  return true;
181
- } catch {
182
- return false;
181
+ } catch (error) {
182
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
183
183
  }
184
184
  }
185
185