@gajae-code/coding-agent 0.7.11 → 0.8.0

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 (78) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +25 -2
  5. package/dist/types/discovery/cursor.d.ts +4 -1
  6. package/dist/types/discovery/index.d.ts +0 -1
  7. package/dist/types/discovery/windsurf.d.ts +3 -1
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +4 -0
  10. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  11. package/dist/types/modes/components/status-line.d.ts +8 -0
  12. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  13. package/dist/types/modes/components/welcome.d.ts +9 -1
  14. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  20. package/dist/types/runtime-mcp/config.d.ts +2 -0
  21. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  22. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  23. package/dist/types/runtime-mcp/types.d.ts +6 -0
  24. package/dist/types/system-prompt.d.ts +7 -0
  25. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  26. package/package.json +10 -10
  27. package/src/capability/mcp.ts +2 -0
  28. package/src/config/mcp-schema.json +5 -1
  29. package/src/config/settings-schema.ts +18 -2
  30. package/src/coordinator-mcp/server.ts +6 -0
  31. package/src/discovery/builtin.ts +12 -0
  32. package/src/discovery/cursor.ts +5 -84
  33. package/src/discovery/gemini.ts +3 -89
  34. package/src/discovery/index.ts +4 -1
  35. package/src/discovery/mcp-json.ts +11 -0
  36. package/src/discovery/opencode.ts +5 -122
  37. package/src/discovery/windsurf.ts +4 -82
  38. package/src/edit/read-file.ts +9 -1
  39. package/src/gjc-runtime/session-state-sidecar.ts +167 -24
  40. package/src/gjc-runtime/team-runtime.ts +56 -51
  41. package/src/internal-urls/docs-index.generated.ts +4 -4
  42. package/src/modes/components/agent-dashboard.ts +1 -34
  43. package/src/modes/components/custom-editor.ts +18 -0
  44. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  45. package/src/modes/components/model-selector.ts +58 -2
  46. package/src/modes/components/session-selector.ts +19 -9
  47. package/src/modes/components/settings-selector.ts +13 -0
  48. package/src/modes/components/status-line.ts +215 -86
  49. package/src/modes/components/tree-selector.ts +28 -8
  50. package/src/modes/components/two-column-body.ts +36 -0
  51. package/src/modes/components/welcome.ts +171 -35
  52. package/src/modes/controllers/event-controller.ts +4 -0
  53. package/src/modes/controllers/input-controller.ts +151 -30
  54. package/src/modes/controllers/selector-controller.ts +25 -28
  55. package/src/modes/interactive-mode.ts +20 -23
  56. package/src/modes/rpc/rpc-types.ts +1 -0
  57. package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
  58. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  59. package/src/modes/types.ts +1 -0
  60. package/src/notifications/index.ts +20 -1
  61. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  62. package/src/prompts/system/system-prompt.md +15 -1
  63. package/src/runtime-mcp/config-writer.ts +28 -0
  64. package/src/runtime-mcp/config.ts +7 -0
  65. package/src/runtime-mcp/loader.ts +3 -0
  66. package/src/runtime-mcp/manager.ts +3 -0
  67. package/src/runtime-mcp/types.ts +6 -0
  68. package/src/sdk.ts +4 -0
  69. package/src/session/agent-session.ts +21 -1
  70. package/src/session/session-manager.ts +58 -18
  71. package/src/system-prompt.ts +8 -0
  72. package/src/task/executor.ts +5 -0
  73. package/src/tools/ast-grep.ts +10 -7
  74. package/src/tools/bash.ts +15 -6
  75. package/src/tools/index.ts +8 -1
  76. package/src/workflow/workflow-intent-diff.ts +199 -0
  77. package/dist/types/discovery/vscode.d.ts +0 -1
  78. package/src/discovery/vscode.ts +0 -105
@@ -1,13 +1,18 @@
1
+ import * as fsSync from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import type { AssistantMessage } from "@gajae-code/ai";
4
- import { logger } from "@gajae-code/utils";
5
+ import { logger, postmortem } from "@gajae-code/utils";
5
6
 
6
7
  export const GJC_COORDINATOR_SESSION_STATE_FILE_ENV = "GJC_COORDINATOR_SESSION_STATE_FILE";
7
8
  export const GJC_COORDINATOR_SESSION_ID_ENV = "GJC_COORDINATOR_SESSION_ID";
9
+ export const GJC_COORDINATOR_SESSION_BRANCH_ENV = "GJC_COORDINATOR_SESSION_BRANCH";
8
10
 
9
11
  export type RuntimeState = "ready_for_input" | "running" | "needs_user_input" | "completed" | "errored";
10
12
 
13
+ type FinalResponseSource = "agent_end" | "launch_error";
14
+ const MAX_PUBLIC_ERROR_MESSAGE_LENGTH = 2000;
15
+
11
16
  interface RuntimeStateEvent {
12
17
  type: string;
13
18
  messages?: unknown[];
@@ -17,6 +22,7 @@ interface RuntimeStateContext {
17
22
  sessionId: string;
18
23
  cwd: string;
19
24
  sessionFile?: string | null;
25
+ branch?: string | null;
20
26
  }
21
27
 
22
28
  interface RuntimeStateSidecarPayload {
@@ -26,6 +32,7 @@ interface RuntimeStateSidecarPayload {
26
32
  ready_for_input?: unknown;
27
33
  cwd?: unknown;
28
34
  session_file?: unknown;
35
+ final_response?: { source?: unknown };
29
36
  }
30
37
 
31
38
  export type TerminalRuntimeStateStatus =
@@ -103,7 +110,7 @@ function assistantText(assistant: AssistantMessage | undefined): string | null {
103
110
  function finalResponseForEvent(event: RuntimeStateEvent): {
104
111
  text: string | null;
105
112
  format: "markdown";
106
- source: "agent_end";
113
+ source: FinalResponseSource;
107
114
  artifact_path: null;
108
115
  truncated: false;
109
116
  } | null {
@@ -127,6 +134,118 @@ function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
127
134
  return null;
128
135
  }
129
136
 
137
+ function readPreviousPayload(stateFile: string): Record<string, unknown> {
138
+ try {
139
+ return JSON.parse(fsSync.readFileSync(stateFile, "utf8")) as Record<string, unknown>;
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+
145
+ function shouldPreserveTerminalPayload(previous: RuntimeStateSidecarPayload): boolean {
146
+ if (previous.state !== "completed" && previous.state !== "errored") return false;
147
+ const source = previous.final_response?.source;
148
+ return source === "agent_end" || source === "launch_error";
149
+ }
150
+
151
+ function branchForContext(context: RuntimeStateContext): string | null {
152
+ return context.branch ?? (process.env[GJC_COORDINATOR_SESSION_BRANCH_ENV]?.trim() || null);
153
+ }
154
+
155
+ function basePayload(input: {
156
+ context: RuntimeStateContext;
157
+ previous: Record<string, unknown>;
158
+ state: RuntimeState;
159
+ now: string;
160
+ source: string;
161
+ event: string;
162
+ reason: string | null;
163
+ }): Record<string, unknown> {
164
+ return {
165
+ schema_version: 1,
166
+ session_id: process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || input.context.sessionId,
167
+ state: input.state,
168
+ ready_for_input: input.state === "completed" || input.state === "ready_for_input",
169
+ updated_at: input.now,
170
+ current_turn_id: typeof input.previous.current_turn_id === "string" ? input.previous.current_turn_id : null,
171
+ last_turn_id: typeof input.previous.last_turn_id === "string" ? input.previous.last_turn_id : null,
172
+ live: input.state === "running",
173
+ reason: input.reason,
174
+ source: input.source,
175
+ event: input.event,
176
+ cwd: input.context.cwd,
177
+ workdir: input.context.cwd,
178
+ branch: branchForContext(input.context),
179
+ session_file: input.context.sessionFile ?? null,
180
+ };
181
+ }
182
+ function publicSafeErrorMessage(message: string): string {
183
+ const normalized = message.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
184
+ if (normalized.length <= MAX_PUBLIC_ERROR_MESSAGE_LENGTH) return normalized;
185
+ return `${normalized.slice(0, MAX_PUBLIC_ERROR_MESSAGE_LENGTH)}…`;
186
+ }
187
+
188
+ function errorMessageForPostmortem(reason: postmortem.Reason): string {
189
+ return publicSafeErrorMessage(`GJC process cleanup ran for ${reason}`);
190
+ }
191
+
192
+ function numericProcessExitCode(defaultCode: number | null): number | null {
193
+ return typeof process.exitCode === "number" ? process.exitCode : defaultCode;
194
+ }
195
+
196
+ function postmortemExitDetails(reason: postmortem.Reason): {
197
+ state: RuntimeState;
198
+ reason: string;
199
+ exitKind: string;
200
+ exitCode: number | null;
201
+ signal: string | null;
202
+ error?: { code: string; message: string; recoverable: true };
203
+ } {
204
+ if (reason === postmortem.Reason.EXIT || reason === postmortem.Reason.MANUAL) {
205
+ const exitCode = numericProcessExitCode(0) ?? 0;
206
+ const state: RuntimeState = exitCode === 0 ? "completed" : "errored";
207
+ return {
208
+ state,
209
+ reason: reason === postmortem.Reason.EXIT ? "process_exit" : "manual_cleanup",
210
+ exitKind: reason,
211
+ exitCode,
212
+ signal: null,
213
+ ...(state === "errored"
214
+ ? {
215
+ error: {
216
+ code: "process_exit",
217
+ message: publicSafeErrorMessage(`GJC process exited with code ${exitCode}`),
218
+ recoverable: true,
219
+ },
220
+ }
221
+ : {}),
222
+ };
223
+ }
224
+ const signalByReason: Partial<Record<postmortem.Reason, string>> = {
225
+ [postmortem.Reason.SIGINT]: "SIGINT",
226
+ [postmortem.Reason.SIGTERM]: "SIGTERM",
227
+ [postmortem.Reason.SIGHUP]: "SIGHUP",
228
+ };
229
+ return {
230
+ state: "errored",
231
+ reason,
232
+ exitKind: reason,
233
+ exitCode: numericProcessExitCode(null),
234
+ signal: signalByReason[reason] ?? null,
235
+ error: { code: reason, message: errorMessageForPostmortem(reason), recoverable: true },
236
+ };
237
+ }
238
+
239
+ function writeStateFileSync(stateFile: string, payload: Record<string, unknown>): void {
240
+ fsSync.mkdirSync(path.dirname(stateFile), { recursive: true });
241
+ fsSync.writeFileSync(stateFile, `${JSON.stringify(payload, null, 2)}\n`);
242
+ }
243
+
244
+ async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
245
+ await fs.mkdir(path.dirname(stateFile), { recursive: true });
246
+ await Bun.write(stateFile, `${JSON.stringify(payload, null, 2)}\n`);
247
+ }
248
+
130
249
  export async function persistCoordinatorRuntimeStateFromEvent(
131
250
  event: RuntimeStateEvent,
132
251
  context: RuntimeStateContext,
@@ -136,42 +255,66 @@ export async function persistCoordinatorRuntimeStateFromEvent(
136
255
  const state = stateForEvent(event);
137
256
  if (!state) return;
138
257
  const now = new Date().toISOString();
139
- let previous: Record<string, unknown> = {};
140
- try {
141
- previous = JSON.parse(await Bun.file(stateFile).text()) as Record<string, unknown>;
142
- } catch {
143
- previous = {};
144
- }
258
+ const previous = readPreviousPayload(stateFile);
145
259
  const finalResponse = finalResponseForEvent(event);
146
260
  const payload = {
147
- schema_version: 1,
148
- session_id: process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId,
149
- state,
150
- ready_for_input: state === "completed" || state === "ready_for_input",
151
- updated_at: now,
152
- current_turn_id: typeof previous.current_turn_id === "string" ? previous.current_turn_id : null,
153
- last_turn_id: typeof previous.last_turn_id === "string" ? previous.last_turn_id : null,
154
- live: typeof previous.live === "boolean" ? previous.live : null,
155
- reason: null,
156
- source: "agent_session_event",
157
- event: event.type,
158
- cwd: context.cwd,
159
- session_file: context.sessionFile ?? null,
261
+ ...basePayload({ context, previous, state, now, source: "agent_session_event", event: event.type, reason: null }),
262
+ ...(state === "completed" || state === "errored" ? { ended_at: now } : {}),
160
263
  ...(finalResponse ? { final_response: finalResponse } : {}),
161
264
  ...(state === "errored"
162
265
  ? {
163
266
  error: {
164
267
  code: "agent_error",
165
- message: lastAssistant(event.messages)?.errorMessage ?? "agent_error",
268
+ message: publicSafeErrorMessage(lastAssistant(event.messages)?.errorMessage ?? "agent_error"),
166
269
  recoverable: true,
167
270
  },
168
271
  }
169
272
  : {}),
170
273
  };
171
274
  try {
172
- await fs.mkdir(path.dirname(stateFile), { recursive: true });
173
- await Bun.write(stateFile, `${JSON.stringify(payload, null, 2)}\n`);
275
+ await writeStateFile(stateFile, payload);
174
276
  } catch (error) {
175
277
  logger.warn("Failed to persist coordinator runtime state", { error: String(error), stateFile });
176
278
  }
177
279
  }
280
+
281
+ export function persistCoordinatorRuntimeStateFromPostmortem(
282
+ reason: postmortem.Reason,
283
+ context: RuntimeStateContext,
284
+ ): void {
285
+ const stateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
286
+ if (!stateFile) return;
287
+ const previous = readPreviousPayload(stateFile);
288
+ if (shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload)) return;
289
+ const now = new Date().toISOString();
290
+ const details = postmortemExitDetails(reason);
291
+ const payload = {
292
+ ...basePayload({
293
+ context,
294
+ previous,
295
+ state: details.state,
296
+ now,
297
+ source: "process_postmortem",
298
+ event: "process_exit",
299
+ reason: details.reason,
300
+ }),
301
+ ended_at: now,
302
+ detected_at: now,
303
+ exit_kind: details.exitKind,
304
+ exit_code: details.exitCode,
305
+ signal: details.signal,
306
+ ...(details.error ? { error: details.error } : {}),
307
+ };
308
+ try {
309
+ writeStateFileSync(stateFile, payload);
310
+ } catch (error) {
311
+ logger.warn("Failed to persist coordinator runtime state during postmortem", { error: String(error), stateFile });
312
+ }
313
+ }
314
+
315
+ export function registerCoordinatorRuntimeStateFinalizer(context: RuntimeStateContext): () => void {
316
+ if (!process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim()) return () => {};
317
+ return postmortem.register("coordinator-runtime-state", reason => {
318
+ persistCoordinatorRuntimeStateFromPostmortem(reason, context);
319
+ });
320
+ }
@@ -1931,16 +1931,14 @@ export function buildWorkerCommand(
1931
1931
  const workspace = worker.worktree_path
1932
1932
  ? `Worker worktree: ${worker.worktree_path}.`
1933
1933
  : `Worker cwd: ${config.leader.cwd}.`;
1934
- // The worker prompt body is dispatched into the pane through
1935
- // `tmux send-keys`, which treats embedded LF characters as Enter
1936
- // keypresses. A multi-line prompt therefore lands in the pane as
1937
- // multiple prompt bodies followed by premature Enter presses, which on
1938
- // Windows + psmux/ConPTY causes the worker CLI to bail out before the
1939
- // startup ACK fires. Normalize the body to a single line by replacing
1940
- // any LF/CRLF with a space, and strip a defensive U+FEFF in case a
1941
- // caller managed to inject a UTF-8 BOM into the task text. Empty /
1942
- // whitespace-only bodies fall back to a one-line placeholder so the
1943
- // worker never sits idle at an empty prompt.
1934
+ // The worker prompt body must stay single-line because fallback startup on
1935
+ // Windows/psmux dispatches the command through `tmux send-keys`, where
1936
+ // embedded LF characters are treated as Enter keypresses. POSIX tmux starts
1937
+ // workers by passing the command directly to `split-window`, but keeping the
1938
+ // body normalized there too makes fake-tmux logs and shell argv handling
1939
+ // deterministic. Strip a defensive U+FEFF in case a caller managed to inject
1940
+ // a UTF-8 BOM into the task text. Empty / whitespace-only bodies fall back to
1941
+ // a one-line placeholder so the worker never sits idle at an empty prompt.
1944
1942
  const normalizePrompt = (raw: string): string =>
1945
1943
  raw
1946
1944
  .replace(/[\uFEFF\u200B]/g, "")
@@ -1995,6 +1993,11 @@ export function buildWorkerCommand(
1995
1993
  }
1996
1994
  return `${joined} ${config.worker_command} ${quote(prompt)}`;
1997
1995
  }
1996
+
1997
+ function shouldDispatchWorkerWithSendKeys(tmuxCommand: string, platform: NodeJS.Platform = process.platform): boolean {
1998
+ return platform === "win32" || path.basename(tmuxCommand).toLowerCase() === "psmux";
1999
+ }
2000
+
1998
2001
  interface GjcTeamInitialLane {
1999
2002
  label: string;
2000
2003
  title: string;
@@ -2098,22 +2101,24 @@ async function startTmuxSession(
2098
2101
  const splitDirection: string = worker.index === 1 ? "-h" : "-v";
2099
2102
  const splitTarget: string =
2100
2103
  worker.index === 1 ? config.leader.pane_id : (rightStackRootPaneId ?? config.leader.pane_id);
2101
- const split: Bun.SyncSubprocess<"pipe", "pipe"> = Bun.spawnSync(
2102
- [
2103
- config.tmux_command,
2104
- "split-window",
2105
- splitDirection,
2106
- "-t",
2107
- splitTarget,
2108
- "-d",
2109
- "-P",
2110
- "-F",
2111
- "#{pane_id}",
2112
- "-c",
2113
- worker.worktree_path ?? config.leader.cwd,
2114
- ],
2115
- { stdout: "pipe", stderr: "pipe" },
2116
- );
2104
+ const workerCommand = buildWorkerCommand(config, worker);
2105
+ const workerCwd = worker.worktree_path ?? config.leader.cwd;
2106
+ const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(config.tmux_command);
2107
+ const splitArgs = [
2108
+ config.tmux_command,
2109
+ "split-window",
2110
+ splitDirection,
2111
+ "-t",
2112
+ splitTarget,
2113
+ "-d",
2114
+ "-P",
2115
+ "-F",
2116
+ "#{pane_id}",
2117
+ "-c",
2118
+ workerCwd,
2119
+ ...(useSendKeysFallback ? [] : [workerCommand]),
2120
+ ];
2121
+ const split: Bun.SyncSubprocess<"pipe", "pipe"> = Bun.spawnSync(splitArgs, { stdout: "pipe", stderr: "pipe" });
2117
2122
  if (split.exitCode !== 0)
2118
2123
  throw new Error(split.stderr.toString().trim() || `tmux_split_failed:${config.tmux_target}:${worker.id}`);
2119
2124
  const paneId: string = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
@@ -2121,31 +2126,31 @@ async function startTmuxSession(
2121
2126
  rollbackPaneIds.push(paneId);
2122
2127
  if (worker.index === 1) rightStackRootPaneId = paneId;
2123
2128
  workers.push({ ...worker, pane_id: paneId });
2124
- // On psmux/ConPTY (Windows pwsh default-shell) panes, tmux writes the
2125
- // split-window command argv to the new pane's stdin but pwsh's
2126
- // readline never receives an Enter, so the worker never starts.
2127
- // Create the pane without a command, then dispatch the worker
2128
- // command through send-keys + Enter so it actually executes.
2129
- // Two-step dispatch because tmux's `-l` (literal mode) flag is
2130
- // global per send-keys invocation, not per arg: passing `-l
2131
- // <body> Enter` would treat "Enter" as literal text too. Sending
2132
- // the body in literal mode first and the Enter keypress second
2133
- // keeps the body verbatim while still submitting the prompt as a
2134
- // keystroke.
2135
- const body = buildWorkerCommand(config, worker);
2136
- Bun.spawnSync([config.tmux_command, "send-keys", "-l", "-t", paneId, body], {
2137
- stdout: "ignore",
2138
- stderr: "ignore",
2139
- });
2140
- const sendKeys = Bun.spawnSync([config.tmux_command, "send-keys", "-t", paneId, "Enter"], {
2141
- stdout: "ignore",
2142
- stderr: "ignore",
2143
- });
2144
- // void-cast the exit code so the linter does not flag an unused
2145
- // expression; the value is intentionally discarded here because
2146
- // the actual spawn outcome is recovered by the leader through
2147
- // the worker startup-ack watcher, not via the spawn exit code.
2148
- void sendKeys.exitCode;
2129
+ if (useSendKeysFallback) {
2130
+ // On psmux/ConPTY (Windows pwsh default-shell) panes, tmux writes the
2131
+ // split-window command argv to the new pane's stdin but pwsh's readline
2132
+ // never receives an Enter, so the worker never starts. Create the pane
2133
+ // without a command, then dispatch the worker command through send-keys +
2134
+ // Enter so it actually executes.
2135
+ // Two-step dispatch because tmux's `-l` (literal mode) flag is global per
2136
+ // send-keys invocation, not per arg: passing `-l <body> Enter` would treat
2137
+ // "Enter" as literal text too. Sending the body in literal mode first and
2138
+ // the Enter keypress second keeps the body verbatim while still submitting
2139
+ // the prompt as a keystroke.
2140
+ Bun.spawnSync([config.tmux_command, "send-keys", "-l", "-t", paneId, workerCommand], {
2141
+ stdout: "ignore",
2142
+ stderr: "ignore",
2143
+ });
2144
+ const sendKeys = Bun.spawnSync([config.tmux_command, "send-keys", "-t", paneId, "Enter"], {
2145
+ stdout: "ignore",
2146
+ stderr: "ignore",
2147
+ });
2148
+ // void-cast the exit code so the linter does not flag an unused expression;
2149
+ // the value is intentionally discarded here because the actual spawn outcome
2150
+ // is recovered by the leader through the worker startup-ack watcher, not via
2151
+ // the spawn exit code.
2152
+ void sendKeys.exitCode;
2153
+ }
2149
2154
  }
2150
2155
  Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
2151
2156
  stdout: "ignore",