@gajae-code/coding-agent 0.11.5 → 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 (138) 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 +67 -7
  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 +4 -0
  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 +13 -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 +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -15,15 +15,12 @@ import type {
15
15
  MCPInitializeParams,
16
16
  MCPInitializeResult,
17
17
  MCPPrompt,
18
- MCPPromptsListResult,
19
18
  MCPRequestOptions,
20
19
  MCPResource,
21
20
  MCPResourceReadParams,
22
21
  MCPResourceReadResult,
23
22
  MCPResourceSubscribeParams,
24
- MCPResourcesListResult,
25
23
  MCPResourceTemplate,
26
- MCPResourceTemplatesListResult,
27
24
  MCPServerCapabilities,
28
25
  MCPServerConfig,
29
26
  MCPServerConnection,
@@ -43,6 +40,9 @@ const PROTOCOL_VERSION = "2025-03-26";
43
40
  /** Default connection timeout in ms */
44
41
  const CONNECTION_TIMEOUT_MS = 30_000;
45
42
 
43
+ const MAX_PAGINATION_PAGES = 100,
44
+ MAX_PAGINATION_ITEMS = 10_000;
45
+
46
46
  /** Client info sent during initialization */
47
47
  const CLIENT_INFO = {
48
48
  name: "gjc-coding-agent",
@@ -109,6 +109,40 @@ function decodeToolsListResult(value: unknown): MCPToolsListResult {
109
109
  return value.nextCursor === undefined ? { tools } : { tools, nextCursor: value.nextCursor };
110
110
  }
111
111
 
112
+ async function collectPaginated<T>(
113
+ connection: MCPServerConnection,
114
+ options: MCPRequestOptions | undefined,
115
+ method: string,
116
+ itemKey: string,
117
+ items: T[],
118
+ decode?: (value: unknown) => unknown,
119
+ ): Promise<void> {
120
+ const seenCursors = new Set<string>();
121
+ const failure = (detail: string) => new MCPExpectedFailure(new Error(`MCP ${method} pagination ${detail}`));
122
+ let cursor: string | undefined;
123
+ for (let page = 1; page <= MAX_PAGINATION_PAGES; page++) {
124
+ const value = await connection.transport.request<unknown>(method, cursor ? { cursor } : {}, options);
125
+ const result = decode ? decode(value) : value;
126
+ if (
127
+ !isRecord(result) ||
128
+ !Array.isArray(result[itemKey]) ||
129
+ (result.nextCursor !== undefined && typeof result.nextCursor !== "string")
130
+ )
131
+ throw new MCPExpectedFailure();
132
+ const nextCursor = result.nextCursor as string | undefined;
133
+ if (nextCursor && seenCursors.has(nextCursor)) throw failure("repeated a cursor");
134
+ const pageItems = result[itemKey] as T[];
135
+ const itemCount = items.length + pageItems.length;
136
+ if (itemCount > MAX_PAGINATION_ITEMS || (itemCount === MAX_PAGINATION_ITEMS && nextCursor))
137
+ throw failure("did not complete within the 10000-item budget");
138
+ items.push(...pageItems);
139
+ if (!nextCursor) return;
140
+ if (page === MAX_PAGINATION_PAGES) throw failure("did not complete within the 100-page budget");
141
+ seenCursors.add(nextCursor);
142
+ cursor = nextCursor;
143
+ }
144
+ }
145
+
112
146
  /**
113
147
  * Default handler for standard MCP server-to-client requests.
114
148
  * Handles `ping` and `roots/list`; rejects unknown methods with -32601.
@@ -292,18 +326,7 @@ export async function listTools(
292
326
  }
293
327
 
294
328
  const allTools: MCPToolDefinition[] = [];
295
- let cursor: string | undefined;
296
-
297
- do {
298
- const params: Record<string, unknown> = {};
299
- if (cursor) {
300
- params.cursor = cursor;
301
- }
302
-
303
- const result = decodeToolsListResult(await connection.transport.request<unknown>("tools/list", params, options));
304
- allTools.push(...result.tools);
305
- cursor = result.nextCursor;
306
- } while (cursor);
329
+ await collectPaginated(connection, options, "tools/list", "tools", allTools, decodeToolsListResult);
307
330
 
308
331
  // Cache tools
309
332
  connection.tools = allTools;
@@ -362,18 +385,7 @@ export async function listResources(
362
385
  }
363
386
 
364
387
  const allResources: MCPResource[] = [];
365
- let cursor: string | undefined;
366
-
367
- do {
368
- const params: Record<string, unknown> = {};
369
- if (cursor) {
370
- params.cursor = cursor;
371
- }
372
-
373
- const result = await connection.transport.request<MCPResourcesListResult>("resources/list", params, options);
374
- allResources.push(...result.resources);
375
- cursor = result.nextCursor;
376
- } while (cursor);
388
+ await collectPaginated(connection, options, "resources/list", "resources", allResources);
377
389
 
378
390
  connection.resources = allResources;
379
391
  return allResources;
@@ -395,22 +407,7 @@ export async function listResourceTemplates(
395
407
  }
396
408
 
397
409
  const allTemplates: MCPResourceTemplate[] = [];
398
- let cursor: string | undefined;
399
-
400
- do {
401
- const params: Record<string, unknown> = {};
402
- if (cursor) {
403
- params.cursor = cursor;
404
- }
405
-
406
- const result = await connection.transport.request<MCPResourceTemplatesListResult>(
407
- "resources/templates/list",
408
- params,
409
- options,
410
- );
411
- allTemplates.push(...result.resourceTemplates);
412
- cursor = result.nextCursor;
413
- } while (cursor);
410
+ await collectPaginated(connection, options, "resources/templates/list", "resourceTemplates", allTemplates);
414
411
 
415
412
  connection.resourceTemplates = allTemplates;
416
413
  return allTemplates;
@@ -514,18 +511,7 @@ export async function listPrompts(
514
511
  }
515
512
 
516
513
  const allPrompts: MCPPrompt[] = [];
517
- let cursor: string | undefined;
518
-
519
- do {
520
- const params: Record<string, unknown> = {};
521
- if (cursor) {
522
- params.cursor = cursor;
523
- }
524
-
525
- const result = await connection.transport.request<MCPPromptsListResult>("prompts/list", params, options);
526
- allPrompts.push(...result.prompts);
527
- cursor = result.nextCursor;
528
- } while (cursor);
514
+ await collectPaginated(connection, options, "prompts/list", "prompts", allPrompts);
529
515
 
530
516
  connection.prompts = allPrompts;
531
517
  return allPrompts;
@@ -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
 
@@ -25,10 +25,14 @@ export type ChatDaemonAction = "stop" | "reload";
25
25
  * Operational generations of the Discord/Slack daemon lifecycle contracts.
26
26
  * These are intentionally separate from per-session endpoint generations.
27
27
  * Generation 6 carries the retained managed filesystem authority boundary.
28
+ * Generation 7 restores macOS daemon signaling (kill(2) with a start-time
29
+ * incarnation recheck) so a live/hung owner can be replaced without an external
30
+ * `kill -9`. Generation 8 adopts Windows expected-identity ACL verification and
31
+ * repair for shared native authority.
28
32
  */
29
33
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
30
- discord: 6,
31
- slack: 6,
34
+ discord: 8,
35
+ slack: 8,
32
36
  };
33
37
 
34
38
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -166,18 +170,27 @@ export interface ChatDaemonProcessReference {
166
170
  }
167
171
 
168
172
  function defaultProcessReference(pid: number, platform = os.platform()): ChatDaemonProcessReference | undefined {
169
- // Process.fromPid on Darwin still resolves identity and then signals a numeric
170
- // PID. A process can exit and its PID be reused in that interval, so daemon
171
- // control must not use it as privileged signaling authority.
172
- if (platform === "darwin") return undefined;
173
173
  try {
174
174
  const processRef = Process.fromPid(pid);
175
175
  if (!processRef || !hasProcessIncarnationAuthority(processRef.incarnation)) return undefined;
176
+ const incarnation = processRef.incarnation;
176
177
  return {
177
- incarnation: processRef.incarnation,
178
+ incarnation,
178
179
  signalRoot: signal => {
179
180
  const nativeSignal = os.constants.signals[signal];
180
181
  if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
182
+ // macOS exposes no pidfd and the native signal_root is a no-op there, so
183
+ // the daemon control plane previously had NO way to signal a live owner —
184
+ // every stop/reload of a live/hung daemon refused, and only an external
185
+ // `kill -9` could recover it. Signal by numeric PID via kill(2), but
186
+ // re-read the immutable start-time incarnation immediately beforehand so a
187
+ // PID that exited and was reused since capture is never signaled.
188
+ if (platform === "darwin") {
189
+ const current = Process.fromPid(pid) as { incarnation?: unknown } | null;
190
+ if (!current || current.incarnation !== incarnation) throw new Error("Pinned process is already gone");
191
+ process.kill(pid, signal);
192
+ return;
193
+ }
181
194
  const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
182
195
  if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
183
196
  },
@@ -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 {
@@ -1926,8 +1925,10 @@ function sdkControlSurface(
1926
1925
  }
1927
1926
  return "unknown";
1928
1927
  };
1929
- const sendSteer = (text: string) => {
1930
- api.sendUserMessage(text, { deliverAs: "steer" });
1928
+ const sendSteer = async (text: string) => {
1929
+ // Await admission so a rejection (e.g. handoff in progress) surfaces as a
1930
+ // control error instead of a false `accepted: true`.
1931
+ await api.sendUserMessage(text, { deliverAs: "steer" });
1931
1932
  return { commandId: crypto.randomUUID(), accepted: true };
1932
1933
  };
1933
1934
  const resolveModel = (id: string) => {
@@ -2776,7 +2777,7 @@ export function createNotificationsExtension(
2776
2777
  sdkHostModeSupported?: boolean;
2777
2778
 
2778
2779
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2779
- runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2780
+ runBtwTurn?: (question: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2780
2781
  /** Observes settlement of optional session-branch startup after reconciliation completes. */
2781
2782
  onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
2782
2783
  readNotificationFile?: (path: string) => Promise<Buffer>;
@@ -3585,10 +3586,10 @@ export function createNotificationsExtension(
3585
3586
  };
3586
3587
 
3587
3588
  const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3588
- if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3589
+ if (!options.runBtwTurn) throw new Error("Ephemeral turns are unavailable.");
3589
3590
  const generation = initializedRuntime.policyGeneration;
3590
3591
  if (initializedRuntime.policySuspended) throw new Error("Notification policy is provisional.");
3591
- const result = await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3592
+ const result = await options.runBtwTurn(question, signal);
3592
3593
  if (
3593
3594
  initializedRuntime.policySuspended ||
3594
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
  }