@gajae-code/coding-agent 0.12.4 → 0.12.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
@@ -80,10 +80,32 @@ async function listFilesRecursively(rootPath: string): Promise<string[]> {
80
80
 
81
81
  return files.sort((a, b) => a.localeCompare(b));
82
82
  }
83
+ function localListingLabel(file: string): string {
84
+ return file.replace(/[\\[\]\r\n]/g, character => {
85
+ switch (character) {
86
+ case "\r":
87
+ return "\\r";
88
+ case "\n":
89
+ return "\\n";
90
+ default:
91
+ return `\\${character}`;
92
+ }
93
+ });
94
+ }
95
+
96
+ function localListingHref(file: string): string {
97
+ return file
98
+ .split("/")
99
+ .map(component => encodeURIComponent(component).replaceAll("(", "%28").replaceAll(")", "%29"))
100
+ .join("/");
101
+ }
83
102
 
84
103
  async function buildListing(url: InternalUrl, localRoot: string): Promise<InternalResource> {
85
104
  const files = await listFilesRecursively(localRoot);
86
- const listing = files.length === 0 ? "(empty)" : files.map(file => `- [${file}](local://${file})`).join("\n");
105
+ const listing =
106
+ files.length === 0
107
+ ? "(empty)"
108
+ : files.map(file => `- [${localListingLabel(file)}](local://${localListingHref(file)})`).join("\n");
87
109
  const content =
88
110
  `# Local\n\n` +
89
111
  `Session-scoped scratch space for large intermediate data, subagent handoffs, and reusable planning artifacts.\n\n` +
@@ -103,31 +125,30 @@ async function buildListing(url: InternalUrl, localRoot: string): Promise<Intern
103
125
  function extractRelativePath(url: InternalUrl): string {
104
126
  const host = url.rawHost || url.hostname;
105
127
  const pathname = url.rawPathname ?? url.pathname;
106
-
107
- const combined = host
108
- ? pathname && pathname !== "/"
109
- ? `${host}${pathname}`
110
- : host
111
- : pathname && pathname !== "/"
112
- ? pathname.slice(1)
113
- : "";
114
-
115
- if (!combined) {
116
- return "";
117
- }
118
-
119
- let decoded: string;
128
+ const encodedAuthority = /^local:\/\/([^/?#]*)/i.exec(url.href)?.[1] ?? "";
129
+ const validatedHost = host;
130
+ let decodedPathname = pathname;
120
131
  try {
121
- decoded = decodeURIComponent(combined.replaceAll("\\", "/"));
132
+ const decodedAuthority = decodeURIComponent(encodedAuthority);
133
+ if (host && decodedAuthority !== host) throw new Error("authority_mismatch");
134
+ decodedPathname = decodeURIComponent(pathname.replaceAll("\\", "/"));
122
135
  } catch {
123
136
  throw new Error(`Invalid URL encoding in local:// path: ${url.href}`);
124
137
  }
138
+ const combined = validatedHost
139
+ ? decodedPathname && decodedPathname !== "/"
140
+ ? `${validatedHost}${decodedPathname}`
141
+ : validatedHost
142
+ : decodedPathname && decodedPathname !== "/"
143
+ ? decodedPathname.slice(1)
144
+ : "";
145
+ if (!combined) return "";
125
146
  try {
126
- validateRelativePath(decoded);
147
+ validateRelativePath(combined);
127
148
  } catch (error) {
128
149
  throw toLocalValidationError(error);
129
150
  }
130
- return decoded;
151
+ return combined;
131
152
  }
132
153
 
133
154
  function safeSessionId(options: LocalProtocolOptions): string {
@@ -2,7 +2,10 @@
2
2
  * Shared helpers for internal-url protocol handlers that resolve session-scoped
3
3
  * artifact IDs.
4
4
  */
5
+
6
+ import * as fs from "node:fs";
5
7
  import * as path from "node:path";
8
+ import { openRecoveryFsRoot, type RecoveryFsRoot } from "@gajae-code/natives";
6
9
  import type { ResolveContext } from "./types";
7
10
 
8
11
  function addDir(dirs: string[], dir: string | null | undefined): void {
@@ -26,3 +29,62 @@ export function authorizedArtifactsDirsFromContext(context?: ResolveContext): st
26
29
  for (const dir of context?.getAuthorizedArtifactsDirs?.() ?? []) addDir(dirs, dir);
27
30
  return dirs;
28
31
  }
32
+
33
+ export interface AuthorizedArtifactsRoot {
34
+ readonly directory: string;
35
+ readonly dev: bigint;
36
+ readonly ino: bigint;
37
+ readonly authority: RecoveryFsRoot;
38
+ }
39
+
40
+ export interface AuthorizedArtifactSnapshot {
41
+ bytes: Buffer;
42
+ size: number;
43
+ }
44
+
45
+ export function openAuthorizedArtifactsRoot(directory: string): AuthorizedArtifactsRoot {
46
+ if (process.platform !== "linux") throw new Error("artifact_authority_unavailable");
47
+ const named = fs.lstatSync(directory, { bigint: true });
48
+ if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("unsafe_artifacts_root");
49
+ const authority = openRecoveryFsRoot(directory);
50
+ const retained = authority.identity();
51
+ if (
52
+ !retained.ok ||
53
+ !retained.identity ||
54
+ retained.identity.dev !== named.dev.toString() ||
55
+ retained.identity.ino !== named.ino.toString()
56
+ ) {
57
+ authority.close();
58
+ throw new Error("unsafe_artifacts_root");
59
+ }
60
+ return { directory, dev: named.dev, ino: named.ino, authority };
61
+ }
62
+
63
+ export function closeAuthorizedArtifactsRoot(root: AuthorizedArtifactsRoot): void {
64
+ root.authority.close();
65
+ }
66
+
67
+ export function readAuthorizedArtifactFile(
68
+ root: AuthorizedArtifactsRoot,
69
+ filename: string,
70
+ maxBytes: number,
71
+ ): AuthorizedArtifactSnapshot | null {
72
+ if (
73
+ path.basename(filename) !== filename ||
74
+ !Number.isSafeInteger(maxBytes) ||
75
+ maxBytes < 0 ||
76
+ maxBytes > 16 * 1024 * 1024
77
+ ) {
78
+ throw new Error("unsafe_artifact_leaf");
79
+ }
80
+ if (!root.authority) throw new Error("artifact_authority_unavailable");
81
+ const captured = root.authority.read(filename, maxBytes);
82
+ if (!captured.ok) {
83
+ if (captured.code === "not_found") return null;
84
+ throw new Error(`unsafe_artifact_leaf:${captured.code ?? "capture_failed"}`);
85
+ }
86
+ if (!captured.data || !captured.identity) throw new Error("unsafe_artifact_leaf:missing_evidence");
87
+ const size = Number(captured.identity.size);
88
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error("unsafe_artifact_leaf:invalid_size");
89
+ return { bytes: Buffer.from(captured.data), size };
90
+ }
@@ -660,6 +660,10 @@ export function createAcpReverseConnection(connection: AgentSideConnection, sess
660
660
  "fs.readTextFile": "fs/read_text_file",
661
661
  "fs.writeTextFile": "fs/write_text_file",
662
662
  "terminal.create": "terminal/create",
663
+ "terminal.output": "terminal/output",
664
+ "terminal.waitForExit": "terminal/wait_for_exit",
665
+ "terminal.kill": "terminal/kill",
666
+ "terminal.release": "terminal/release",
663
667
  "ui.elicit": "elicitation/create",
664
668
  };
665
669
  return {
@@ -668,6 +672,26 @@ export function createAcpReverseConnection(connection: AgentSideConnection, sess
668
672
  params: JsonObject,
669
673
  options?: { cancellationSignal?: AbortSignal },
670
674
  ): Promise<unknown> => {
675
+ if (method === "terminal.publish") {
676
+ const toolCallId = typeof params.toolCallId === "string" ? params.toolCallId : undefined;
677
+ const terminalId = typeof params.terminalId === "string" ? params.terminalId : undefined;
678
+ if (!toolCallId || !terminalId) {
679
+ throw new AcpSdkAdapterError(
680
+ "invalid_params",
681
+ "ACP terminal publication requires toolCallId and terminalId.",
682
+ );
683
+ }
684
+ await connection.sessionUpdate({
685
+ sessionId,
686
+ update: {
687
+ sessionUpdate: "tool_call_update",
688
+ toolCallId,
689
+ status: "in_progress",
690
+ content: [{ type: "terminal", terminalId }],
691
+ },
692
+ });
693
+ return {};
694
+ }
671
695
  const name = methods[method];
672
696
  if (!name)
673
697
  throw new AcpSdkAdapterError("acp_reverse_unavailable", `ACP reverse method is unavailable: ${method}`);
@@ -718,6 +742,7 @@ export class AcpAgent implements Agent {
718
742
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
719
743
  readonly #knownSessionCwds = new Map<string, string>();
720
744
  readonly #knownSessionMetadata = new Map<string, { title?: string; updatedAt?: string }>();
745
+ readonly #pendingDeleteLocators = new Map<string, { cwd: string; path: string }>();
721
746
  readonly #pendingCloseIdempotencyKeys = new Map<string, string>();
722
747
  readonly #sessionEpochs = new Map<string, number>();
723
748
  readonly #tearingDown = new Map<string, number>();
@@ -918,24 +943,35 @@ export class AcpAgent implements Agent {
918
943
 
919
944
  async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
920
945
  const record = this.#sessions.get(params.sessionId);
921
- const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
922
- // ACP's delete request has no cwd. Only delete sessions this connection has
923
- // already scoped through the broker; unknown ids remain the protocol no-op.
924
- if (!cwd) return {};
946
+ const pendingLocator = this.#pendingDeleteLocators.get(params.sessionId);
947
+ const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId) ?? pendingLocator?.cwd;
948
+ // ACP's delete request has no cwd. Unknown ids remain the protocol no-op,
949
+ // while the broker can reconstruct an authenticated pending locator from its durable ledger.
950
+ if (!cwd) {
951
+ await (await this.#brokerAdapter()).global(
952
+ "session.delete",
953
+ { sessionId: params.sessionId },
954
+ this.#lifecycleIdempotencyKey(params.sessionId, "session.delete"),
955
+ );
956
+ return {};
957
+ }
925
958
  this.#beginTeardown(params.sessionId);
926
959
  try {
927
960
  await this.#teardownSession(params.sessionId, "deleted", true);
928
- let saved: string;
929
- try {
930
- saved = await this.#resolveSavedSession(params.sessionId, cwd);
931
- } catch (error) {
932
- if (error instanceof AcpSdkAdapterError && error.code === "not_found") {
933
- this.#knownSessionCwds.delete(params.sessionId);
934
- this.#knownSessionMetadata.delete(params.sessionId);
935
- return {};
961
+ let saved = pendingLocator?.cwd === cwd ? pendingLocator.path : undefined;
962
+ if (!saved) {
963
+ try {
964
+ saved = await this.#resolveSavedSession(params.sessionId, cwd);
965
+ } catch (error) {
966
+ if (error instanceof AcpSdkAdapterError && error.code === "not_found") {
967
+ this.#knownSessionCwds.delete(params.sessionId);
968
+ this.#knownSessionMetadata.delete(params.sessionId);
969
+ return {};
970
+ }
971
+ throw error;
936
972
  }
937
- throw error;
938
973
  }
974
+ this.#pendingDeleteLocators.set(params.sessionId, { cwd, path: saved });
939
975
  await (await this.#brokerAdapter()).global(
940
976
  "session.delete",
941
977
  { sessionId: params.sessionId, sessionPath: saved, cwd, target: { path: cwd } },
@@ -943,6 +979,7 @@ export class AcpAgent implements Agent {
943
979
  );
944
980
  this.#knownSessionCwds.delete(params.sessionId);
945
981
  this.#knownSessionMetadata.delete(params.sessionId);
982
+ this.#pendingDeleteLocators.delete(params.sessionId);
946
983
  return {};
947
984
  } finally {
948
985
  this.#finishTeardown(params.sessionId);
@@ -2168,6 +2205,7 @@ export class AcpAgent implements Agent {
2168
2205
  this.#resolvingExisting.clear();
2169
2206
  this.#knownSessionCwds.clear();
2170
2207
  this.#knownSessionMetadata.clear();
2208
+ this.#pendingDeleteLocators.clear();
2171
2209
  this.#pendingCloseIdempotencyKeys.clear();
2172
2210
  if (this.#closing.size === 0) this.#closing.clear();
2173
2211
  this.#tearingDown.clear();
@@ -4,6 +4,8 @@
4
4
  * `AgentSession`) can route through the client when it advertises the
5
5
  * relevant capabilities at `initialize` time.
6
6
  */
7
+
8
+ import { randomUUID } from "node:crypto";
7
9
  import type {
8
10
  PermissionOption as AcpPermissionOption,
9
11
  TerminalHandle as AcpTerminalHandle,
@@ -19,10 +21,25 @@ import type {
19
21
  ClientBridgePermissionOption,
20
22
  ClientBridgePermissionOutcome,
21
23
  ClientBridgePermissionToolCall,
24
+ ClientBridgeProviderLeaseIdentity,
22
25
  ClientBridgeTerminalHandle,
23
26
  } from "../../session/client-bridge";
24
27
  import { resolveAcpPermissionMode } from "./permission-mode";
25
28
 
29
+ const ACP_TERMINAL_PUBLICATION_WAIT_MS = 500;
30
+
31
+ async function settleTerminalCleanup(operation: () => Promise<unknown>): Promise<boolean> {
32
+ return await Promise.race([
33
+ Promise.resolve()
34
+ .then(operation)
35
+ .then(
36
+ () => true,
37
+ () => false,
38
+ ),
39
+ Bun.sleep(ACP_TERMINAL_PUBLICATION_WAIT_MS).then(() => false),
40
+ ]);
41
+ }
42
+
26
43
  export { type AcpPermissionMode, resolveAcpPermissionMode } from "./permission-mode";
27
44
 
28
45
  export function createAcpClientBridge(
@@ -39,6 +56,15 @@ export function createAcpClientBridge(
39
56
  };
40
57
 
41
58
  const bridge: ClientBridge = { capabilities, deferAgentInitiatedTurns: true };
59
+ const connectionId = `acp:${sessionId}`;
60
+ const connectionGeneration = 1;
61
+ const terminalProviderLeaseId = `${connectionId}:${randomUUID()}`;
62
+ const terminalProviderIdentity: ClientBridgeProviderLeaseIdentity = {
63
+ leaseId: terminalProviderLeaseId,
64
+ connectionId,
65
+ connectionGeneration,
66
+ fence: randomUUID(),
67
+ };
42
68
 
43
69
  if (capabilities.readTextFile) {
44
70
  bridge.readTextFile = async params => {
@@ -63,8 +89,49 @@ export function createAcpClientBridge(
63
89
  }
64
90
 
65
91
  if (capabilities.terminal) {
92
+ let terminalPublicationFailed = false;
93
+ bridge.quarantineProviderLease = async (providerLease, _reason) => {
94
+ if (
95
+ typeof providerLease === "string" ||
96
+ providerLease.leaseId !== terminalProviderIdentity.leaseId ||
97
+ providerLease.connectionId !== terminalProviderIdentity.connectionId ||
98
+ providerLease.connectionGeneration !== terminalProviderIdentity.connectionGeneration ||
99
+ providerLease.fence !== terminalProviderIdentity.fence
100
+ )
101
+ throw new Error("terminal provider lease mismatch");
102
+ terminalPublicationFailed = true;
103
+ };
104
+ let terminalCreationTail = Promise.resolve();
105
+ const withTerminalCreationAdmission = async <T>(operation: () => Promise<T>): Promise<T> => {
106
+ const previous = terminalCreationTail;
107
+ const release = Promise.withResolvers<void>();
108
+ terminalCreationTail = previous.then(() => release.promise);
109
+ await previous;
110
+ try {
111
+ return await operation();
112
+ } finally {
113
+ release.resolve();
114
+ }
115
+ };
66
116
  bridge.createTerminal = (params: ClientBridgeCreateTerminalParams) =>
67
- createTerminalHandle(connection, sessionId, params);
117
+ withTerminalCreationAdmission(async () => {
118
+ if (terminalPublicationFailed) throw new Error("terminal provider quarantined after publication failure");
119
+ const creationTimeout = AbortSignal.timeout(ACP_TERMINAL_PUBLICATION_WAIT_MS);
120
+ const creationSignal = params.signal ? AbortSignal.any([params.signal, creationTimeout]) : creationTimeout;
121
+ return await createTerminalHandle(
122
+ connection,
123
+ sessionId,
124
+ params,
125
+ creationSignal,
126
+ terminalProviderIdentity,
127
+ () => {
128
+ terminalPublicationFailed = true;
129
+ },
130
+ () => {
131
+ terminalPublicationFailed = true;
132
+ },
133
+ );
134
+ });
68
135
  }
69
136
 
70
137
  if (promptPermission) {
@@ -79,21 +146,97 @@ async function createTerminalHandle(
79
146
  connection: AgentSideConnection,
80
147
  sessionId: string,
81
148
  params: ClientBridgeCreateTerminalParams,
149
+ creationSignal: AbortSignal,
150
+ providerIdentity: ClientBridgeProviderLeaseIdentity,
151
+ onCreationUncertain: () => void,
152
+ onPublicationFailure: () => void,
82
153
  ): Promise<ClientBridgeTerminalHandle> {
83
- const handle = await connection.createTerminal({
84
- sessionId,
85
- command: params.command,
86
- ...(params.args ? { args: params.args } : {}),
87
- ...(params.env ? { env: params.env } : {}),
88
- ...(params.cwd ? { cwd: params.cwd } : {}),
89
- ...(typeof params.outputByteLimit === "number" ? { outputByteLimit: params.outputByteLimit } : {}),
90
- });
91
- return wrapTerminalHandle(handle);
154
+ if (creationSignal.aborted) throw new Error("terminal creation aborted");
155
+ const creation = Promise.resolve().then(() =>
156
+ connection.createTerminal({
157
+ sessionId,
158
+ command: params.command,
159
+ ...(params.args ? { args: params.args } : {}),
160
+ ...(params.env ? { env: params.env } : {}),
161
+ ...(params.cwd ? { cwd: params.cwd } : {}),
162
+ ...(typeof params.outputByteLimit === "number" ? { outputByteLimit: params.outputByteLimit } : {}),
163
+ }),
164
+ );
165
+ const creationAborted = Promise.withResolvers<"aborted">();
166
+ const onCreationAbort = () => creationAborted.resolve("aborted");
167
+ creationSignal.addEventListener("abort", onCreationAbort, { once: true });
168
+ if (creationSignal.aborted) creationAborted.resolve("aborted");
169
+ const creationOutcome = await Promise.race([
170
+ creation.then(
171
+ handle => ({ status: "created" as const, handle }),
172
+ error => ({ status: "failed" as const, error }),
173
+ ),
174
+ creationAborted.promise.then(() => ({ status: "aborted" as const })),
175
+ ]);
176
+ creationSignal.removeEventListener("abort", onCreationAbort);
177
+ if (creationOutcome.status === "aborted") {
178
+ onCreationUncertain();
179
+ void creation.then(
180
+ async handle => {
181
+ await settleTerminalCleanup(() => handle.kill());
182
+ await settleTerminalCleanup(() => handle.release());
183
+ },
184
+ () => undefined,
185
+ );
186
+ throw new Error("terminal creation aborted");
187
+ }
188
+ if (creationOutcome.status === "failed") {
189
+ onCreationUncertain();
190
+ throw creationOutcome.error;
191
+ }
192
+ const handle = creationOutcome.handle;
193
+ const publication = Promise.resolve().then(() =>
194
+ connection.sessionUpdate({
195
+ sessionId,
196
+ update: {
197
+ sessionUpdate: "tool_call_update",
198
+ toolCallId: params.toolCallId,
199
+ status: "in_progress",
200
+ content: [{ type: "terminal", terminalId: handle.id }],
201
+ },
202
+ }),
203
+ );
204
+ const aborted = Promise.withResolvers<"aborted">();
205
+ const onAbort = () => aborted.resolve("aborted");
206
+ params.signal?.addEventListener("abort", onAbort, { once: true });
207
+ if (params.signal?.aborted) aborted.resolve("aborted");
208
+ const outcome = await Promise.race([
209
+ publication.then(
210
+ () => ({ status: "published" as const }),
211
+ error => ({ status: "failed" as const, error }),
212
+ ),
213
+ Bun.sleep(ACP_TERMINAL_PUBLICATION_WAIT_MS).then(() => ({ status: "timeout" as const })),
214
+ aborted.promise.then(() => ({ status: "aborted" as const })),
215
+ ]);
216
+ params.signal?.removeEventListener("abort", onAbort);
217
+ if (outcome.status !== "published") {
218
+ const killed = await settleTerminalCleanup(() => handle.kill());
219
+ const released = await settleTerminalCleanup(() => handle.release());
220
+ if (!params.signal?.aborted || !killed || !released) onPublicationFailure();
221
+ if (outcome.status === "failed") throw outcome.error;
222
+ throw new Error(
223
+ outcome.status === "aborted"
224
+ ? "terminal publication aborted"
225
+ : `terminal publication did not settle within ${ACP_TERMINAL_PUBLICATION_WAIT_MS}ms`,
226
+ );
227
+ }
228
+ return wrapTerminalHandle(handle, providerIdentity.leaseId, providerIdentity);
92
229
  }
93
230
 
94
- function wrapTerminalHandle(handle: AcpTerminalHandle): ClientBridgeTerminalHandle {
231
+ function wrapTerminalHandle(
232
+ handle: AcpTerminalHandle,
233
+ providerLeaseId: string,
234
+ providerLeaseIdentity: ClientBridgeProviderLeaseIdentity,
235
+ ): ClientBridgeTerminalHandle {
95
236
  return {
96
237
  terminalId: handle.id,
238
+ providerLeaseId,
239
+ providerLeaseIdentity,
97
240
  async currentOutput() {
98
241
  const out = await handle.currentOutput();
99
242
  return {
@@ -56,6 +56,7 @@ export interface GjcBundleSettingsDependencies {
56
56
  export interface GjcBundleSettingsCallbacks {
57
57
  onClose: () => void;
58
58
  onBundlesChanged?: () => void;
59
+ onRenderRequested?: () => void;
59
60
  }
60
61
 
61
62
  export type GjcBundleSettingsState =
@@ -187,6 +188,7 @@ export class GjcBundleSettingsComponent extends Container {
187
188
  ),
188
189
  );
189
190
  this.addChild(new DynamicBorder());
191
+ this.callbacks.onRenderRequested?.();
190
192
  }
191
193
 
192
194
  #renderList(): void {
@@ -127,6 +127,7 @@ export interface PluginDetailCallbacks {
127
127
  onFeatureChange: (feature: string, enabled: boolean) => void;
128
128
  onConfigChange: (key: string, value: unknown) => void;
129
129
  onBack: () => void;
130
+ onRenderRequested?: () => void;
130
131
  }
131
132
 
132
133
  /**
@@ -288,6 +289,7 @@ export class PluginDetailComponent extends Container {
288
289
  this.addChild(new Spacer(1));
289
290
  this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0));
290
291
  this.addChild(new DynamicBorder());
292
+ this.callbacks.onRenderRequested?.();
291
293
  }
292
294
 
293
295
  handleInput(data: string): void {
@@ -409,6 +411,7 @@ class ConfigInputSubmenu extends Container {
409
411
  export interface PluginSettingsCallbacks {
410
412
  onClose: () => void;
411
413
  onPluginChanged: () => void;
414
+ onRenderRequested?: () => void;
412
415
  }
413
416
 
414
417
  /** Component with handleInput method */
@@ -450,6 +453,7 @@ export class PluginSettingsComponent extends Container {
450
453
  });
451
454
 
452
455
  this.addChild(this.#viewComponent);
456
+ this.callbacks.onRenderRequested?.();
453
457
  }
454
458
 
455
459
  #showPluginDetail(plugin: InstalledPlugin): void {
@@ -477,6 +481,7 @@ export class PluginSettingsComponent extends Container {
477
481
  this.callbacks.onPluginChanged();
478
482
  },
479
483
  onBack: () => this.#showPluginList(),
484
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
480
485
  });
481
486
 
482
487
  this.addChild(this.#viewComponent);
@@ -719,6 +719,8 @@ export interface SettingsCallbacks {
719
719
  getStatusLinePreview?: (width?: number) => string;
720
720
  /** Called when plugins change */
721
721
  onPluginsChanged?: () => void;
722
+ /** Called when asynchronously rebuilt settings content needs a repaint. */
723
+ onRenderRequested?: () => void;
722
724
  /** Called when an interactive setting cannot be committed. */
723
725
  onError?: (message: string) => void;
724
726
  /** Called when settings panel is closed */
@@ -1287,6 +1289,7 @@ export class SettingsSelectorComponent extends Container {
1287
1289
  this.#pluginComponent = new PluginSettingsComponent(this.context.cwd, {
1288
1290
  onClose: () => this.callbacks.onCancel(),
1289
1291
  onPluginChanged: () => this.callbacks.onPluginsChanged?.(),
1292
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
1290
1293
  });
1291
1294
  this.addChild(this.#pluginComponent);
1292
1295
  }
@@ -1296,6 +1299,7 @@ export class SettingsSelectorComponent extends Container {
1296
1299
  {
1297
1300
  onClose: () => this.callbacks.onCancel(),
1298
1301
  onBundlesChanged: () => this.callbacks.onPluginsChanged?.(),
1302
+ onRenderRequested: () => this.callbacks.onRenderRequested?.(),
1299
1303
  },
1300
1304
  {
1301
1305
  runtimeSnapshotProvider: this.context.gjcRuntimeSnapshot,
@@ -1334,6 +1334,7 @@ export class SelectorController {
1334
1334
  onPluginsChanged: () => {
1335
1335
  this.ctx.ui.requestRender();
1336
1336
  },
1337
+ onRenderRequested: () => this.ctx.ui.requestRender(),
1337
1338
  onCancel: () => {
1338
1339
  done();
1339
1340
  // Restore status line to saved settings
@@ -65,5 +65,5 @@ Use it only for sanctioned GJC workflow CLI persistence or state read/write/cont
65
65
  # Output minimizer
66
66
 
67
67
  - Bash stdout/stderr may be rewritten before you see it: long output keeps only the last 1 KiB by default to reduce noise and input-token use. Explicit `tools.artifactTailBytes` / `tools.artifactHeadBytes` settings can set the tail budget or retain both ends. Prefer focused commands and dedicated `search`/`find` tools over producing broad output. Test/lint runners (e.g. `bun test`, `cargo test`, ESLint) are also passed through heuristic filters that drop noise and keep failures.
68
- - When the local minimizer changes visible text, successful artifact storage appends a footer containing an `artifact://<id>` reference. Complete artifacts are labeled as full output; hard-capped artifacts report omitted bytes instead. If artifact allocation/storage is unavailable before a writer/save operation is attempted, truncation may have no reference or diagnostic. If an artifact writer/save operation is attempted and fails, a bounded diagnostic is emitted without inventing an artifact URI.
69
- - ACP/client-terminal output can arrive already truncated from the beginning. Treat any truncation notice or metadata as evidence that the visible tail is incomplete. Recover omitted output only when an `artifact://` footer or metadata reference is present; truncation evidence without a reference means the visible output is incomplete and no artifact is recoverable. Output with neither truncation evidence nor an artifact reference is the complete emitted output.
68
+ - When the local minimizer changes visible text, successful artifact storage appends a footer containing an `artifact://<id>` reference. Artifact allocation is best-effort on direct, async, and monitor paths, and only decimal session artifact ids are accepted. Native callback delivery bounds both the core-to-native relay and N-API queue with backpressure and preserves a 64 KiB terminal tail after its core stream budget, emitting at most one typed aggregate loss marker. Complete artifacts are labeled as full output; hard-capped artifacts, streams that lost bytes before Bash capture, reader/client captures that cannot prove completeness, and loss counters beyond JavaScript's exact integer range report incompleteness instead. If artifact allocation/storage is unavailable before a writer/save operation is attempted, truncation may have no reference or diagnostic. If an artifact writer/save operation is attempted and fails, a bounded diagnostic is emitted without inventing an artifact URI.
69
+ - ACP/client-terminal output can arrive already truncated from the beginning. Terminal creation, kill, output-recovery, and release RPCs are settlement-bounded; stalled or failed exit/kill/output operations report source-capture uncertainty instead of hanging or claiming completeness. Treat any truncation notice or metadata as evidence that the visible tail is incomplete. Recover omitted output only when an `artifact://` footer or metadata reference is present; truncation evidence without a reference means the visible output is incomplete and no artifact is recoverable. Output with neither truncation evidence nor an artifact reference is the complete emitted output.
@@ -1,8 +1,8 @@
1
- Start a background monitor that streams events from a long-running script. Each stdout line is an event — you keep working and notifications arrive in the chat. Events arrive on their own schedule and are not replies from the user, even if one lands while you're waiting for the user to answer a question.
1
+ Start a background monitor that streams events from a long-running script. Each stdout line is captured; persistent notifications are latest-biased and coalesced over a short debounce window, while terminal completion flushes the newest pending line. Events arrive on their own schedule and are not replies from the user, even if one lands while you're waiting for the user to answer a question.
2
2
 
3
3
  Pick by how many notifications you need:
4
4
  - **One** ("tell me when the server is ready / the build finishes") → use `bash` with `async: true`. That returns a single completion notification when the command exits.
5
- - **Many ongoing events** (logs, polling, file watching) → use `monitor`. The script keeps running and every new line of stdout becomes one event delivered into the conversation between turns.
5
+ - **Many ongoing events** (logs, polling, file watching) → use `monitor`. The script keeps running and new stdout is captured; persistent notifications are coalesced so ordinary log traffic does not create one model turn per line.
6
6
 
7
7
  `monitor` uses the same permission rules as `bash`. To stop a monitor, cancel its background task via `job` with the returned `task_id`, or end the session.
8
8
 
@@ -15,7 +15,7 @@ Pick by how many notifications you need:
15
15
 
16
16
  ## Inputs
17
17
 
18
- - `command` (required): shell command to run as a background monitor. Each stdout line is delivered as a separate task-notification event.
18
+ - `command` (required): shell command to run as a background monitor. Stdout is captured line-by-line; persistent notifications are coalesced before delivery.
19
19
  - `kind` (required): one of `"log"`, `"poll"`, `"watch"`, `"other"`. Describes the monitoring strategy so listings can surface useful categories.
20
20
  - `description` (required): short human-readable description of what is being monitored. Appears in task listings.
21
21
  - `timeout` (optional): maximum wall-clock seconds the monitor may run before automatic shutdown. Omit for the session lifetime.
@@ -23,7 +23,7 @@ Pick by how many notifications you need:
23
23
 
24
24
  ## Output
25
25
 
26
- Returns `Monitor started · task <task_id>` plus a task entry visible via `job({list: true})`. Each stdout line of the monitored command becomes a `<task-notification>` event delivered between turns.
26
+ Returns `Monitor started · task <task_id>` plus a task entry visible via `job({list: true})`. Persistent notifications contain the latest line and the count of earlier coalesced lines; terminal completion flushes the newest pending line.
27
27
 
28
28
  ## Cancellation
29
29