@gajae-code/coding-agent 0.16.1 → 0.16.4

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
@@ -66,6 +66,26 @@ function normalizeSkillName(name: string | undefined): string {
66
66
  }
67
67
 
68
68
  const SKILL_NAME_GLOB_PATTERN = /[*?[\]{}]/;
69
+ const MAX_AVAILABLE_SKILL_NAMES = 50;
70
+ const MAX_AVAILABLE_SKILL_CHARS = 2048;
71
+
72
+ function formatAvailableSkills(skills: readonly { name: string }[]): string {
73
+ const names = [...new Set(skills.map(skill => skill.name))].sort();
74
+ const visible: string[] = [];
75
+ let visibleChars = 0;
76
+ for (let index = 0; index < names.length && visible.length < MAX_AVAILABLE_SKILL_NAMES; index++) {
77
+ const name = names[index];
78
+ const separatorChars = visible.length > 0 ? 2 : 0;
79
+ const omittedAfterAdding = names.length - (visible.length + 1);
80
+ const markerChars = omittedAfterAdding > 0 ? `, … (+${omittedAfterAdding} more)`.length : 0;
81
+ if (visibleChars + separatorChars + name.length + markerChars > MAX_AVAILABLE_SKILL_CHARS) break;
82
+ visible.push(name);
83
+ visibleChars += separatorChars + name.length;
84
+ }
85
+ const omitted = names.length - visible.length;
86
+ const marker = omitted > 0 ? `… (+${omitted} more)` : "";
87
+ return visible.length > 0 && marker ? `${visible.join(", ")}, ${marker}` : visible.join(", ") || marker;
88
+ }
69
89
 
70
90
  type SkillToolInput = z.infer<typeof skillSchema>;
71
91
 
@@ -154,10 +174,10 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
154
174
  this.#session.home,
155
175
  ));
156
176
  if (!skill) {
157
- const available = skills.map(s => s.name).sort();
177
+ const available = formatAvailableSkills(skills);
158
178
  const hint =
159
179
  available.length > 0
160
- ? ` Available: ${available.join(", ")}. Use skill_discovery to find project/user runtime skills.`
180
+ ? ` Available: ${available}. Use skill_discovery to find project/user runtime skills.`
161
181
  : " Use skill_discovery to find project/user runtime skills.";
162
182
  throw new ToolError(`skill tool: unknown skill "${requestedName}".${hint}`);
163
183
  }
@@ -227,7 +227,9 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
227
227
  // or belong to a same-id subagent of another session (review thread P1).
228
228
  const manager =
229
229
  this.session.getAsyncJobManager?.() ??
230
- AsyncJobManager.forEndpoint(this.session.getSessionId?.() ?? undefined) ??
230
+ AsyncJobManager.forEndpoint(
231
+ this.session.getAsyncEndpointId?.() ?? this.session.getSessionId?.() ?? undefined,
232
+ ) ??
231
233
  AsyncJobManager.instance();
232
234
  if (!manager) {
233
235
  return {
@@ -988,7 +988,7 @@ export const TOOL_CATALOG: Readonly<Record<string, ToolCatalogEntry>> = {
988
988
  "search": {
989
989
  "name": "search",
990
990
  "label": "Search",
991
- "description": "Searches files using powerful regex matching.\n\n<instruction>\n- Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)\n- `paths` accepts an array of files, directories, globs, or internal URLs; when omitted, the whole working directory is searched\n- `paths` is an array; do not embed commas or spaces inside a single entry. Pass `[\"src\", \"tests\"]` not `[\"src,tests\"]`.\n- Cross-line patterns are detected from literal `\\n` or escaped `\\\\n` in `pattern`\n</instruction>\n\n<output>\n</output>\n\n<critical>\n- Search paths are an array; pass separate entries rather than comma-joined paths.\n- Use a cross-line pattern only when the match actually spans lines.\n</critical>",
991
+ "description": "Searches files using powerful regex matching.\n\n<instruction>\n- Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)\n- `paths` accepts an array of files, directories, globs, or internal URLs; when omitted, the whole working directory is searched\n- `paths` is an array; do not embed commas or spaces inside a single entry. Pass `[\"src\", \"tests\"]` not `[\"src,tests\"]`.\n- Cross-line patterns are detected from literal `\\n` or escaped `\\\\n` in `pattern`\n- `timeout` is in seconds (default 5, range 0.5–60). Increase it only for intentionally broad searches; narrow `paths` first\n</instruction>\n\n<output>\n</output>\n\n<critical>\n- Search paths are an array; pass separate entries rather than comma-joined paths.\n- Use a cross-line pattern only when the match actually spans lines.\n- Do not retry a timed-out search unchanged; narrow `paths`/`pattern` or explicitly increase `timeout`.\n</critical>",
992
992
  "parameters": {
993
993
  "type": "object",
994
994
  "properties": {
@@ -1016,6 +1016,13 @@ export const TOOL_CATALOG: Readonly<Record<string, ToolCatalogEntry>> = {
1016
1016
  "skip": {
1017
1017
  "description": "files to skip before collecting results — use to paginate when the prior call hit the file limit",
1018
1018
  "type": "number"
1019
+ },
1020
+ "timeout": {
1021
+ "default": 5,
1022
+ "description": "timeout in seconds (0.5–60)",
1023
+ "type": "number",
1024
+ "minimum": 0.5,
1025
+ "maximum": 60
1019
1026
  }
1020
1027
  },
1021
1028
  "required": [
@@ -2319,7 +2326,7 @@ export const TOOL_CATALOG: Readonly<Record<string, ToolCatalogEntry>> = {
2319
2326
  "job": {
2320
2327
  "name": "job",
2321
2328
  "label": "Job",
2322
- "description": "Inspects, waits, or cancels async jobs.\n\nBackground job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.\n\nIn the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer` and `toolInterruptPolicy=abort_tools`). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. Job listings expose `foldReason` (`steer`, `chord`, `timer`, or `sdk_control`) beside `backgrounded` so you can tell why a job left the foreground. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.\n\n# Operations\n\n## `list: true`\nUse to inspect what's running.\n\n## `tail: [id, …]`\nShow the retained output buffer for one or more background jobs without waiting.\n- Use this to reopen/tail a backgrounded long-running bash/tool output after folding it away.\n- Output is bounded by the manager retention window; stale cursors may report that only the retained tail is available.\n- Prefer `tail` over polling when you only need to peek at progress, so the conversation can continue without flooding the TUI.\n\n## `poll: [id, …]`\nBlock until the specified jobs finish or the wait window (~30 s, not configurable) elapses.\n- Use when you are genuinely blocked on a result and have no other work to do.\n- Returns the current snapshot when the timer elapses; running jobs remain running.\n- Completed jobs include their final output in the returned snapshot.\n\n## `cancel: [id, …]`\nStop running jobs.\n- Use when a job is stalled, hung, or no longer needed.\n- Returns immediately after cancelling.",
2329
+ "description": "Inspects, waits, or cancels async jobs.\n\nBackground job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.\n\nIn the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer` (`toolInterruptPolicy` is not a gate)). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. Job listings expose `foldReason` (`steer`, `chord`, `timer`, or `sdk_control`) beside `backgrounded` so you can tell why a job left the foreground. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.\n\n# Operations\n\n## `list: true`\nUse to inspect what's running.\n\n## `tail: [id, …]`\nShow the retained output buffer for one or more background jobs without waiting.\n- Use this to reopen/tail a backgrounded long-running bash/tool output after folding it away.\n- Output is bounded by the manager retention window; stale cursors may report that only the retained tail is available.\n- Prefer `tail` over polling when you only need to peek at progress, so the conversation can continue without flooding the TUI.\n\n## `poll: [id, …]`\nBlock until the specified jobs finish or the wait window (~30 s, not configurable) elapses.\n- Use when you are genuinely blocked on a result and have no other work to do.\n- Returns the current snapshot when the timer elapses; running jobs remain running.\n- Completed jobs include their final output in the returned snapshot.\n\n## `cancel: [id, …]`\nStop running jobs.\n- Use when a job is stalled, hung, or no longer needed.\n- Returns immediately after cancelling.",
2323
2330
  "parameters": {
2324
2331
  "type": "object",
2325
2332
  "properties": {
@@ -65,8 +65,22 @@ const ENABLE_ECHO_INPUT = 0x0004;
65
65
  const ENABLE_WINDOW_INPUT = 0x0008;
66
66
  const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
67
67
 
68
- function prepareWindowsConsoleForExternalEditor(): void {
69
- if (process.platform !== "win32") return;
68
+ interface RawModeInput {
69
+ setRawMode?: (mode: boolean) => unknown;
70
+ }
71
+
72
+ export function prepareWindowsConsoleForExternalEditor(
73
+ platform: NodeJS.Platform = process.platform,
74
+ input: RawModeInput = process.stdin,
75
+ ): void {
76
+ if (platform !== "win32") return;
77
+ // Native Windows terminals can expose stdin through a ConPTY pipe rather than
78
+ // a console input handle. In that case GetConsoleMode/SetConsoleMode below
79
+ // cannot re-arm the input stream, but the runtime's TTY implementation can.
80
+ try {
81
+ input.setRawMode?.(true);
82
+ } catch {}
83
+
70
84
  try {
71
85
  const kernel32 = dlopen("kernel32.dll", {
72
86
  GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
@@ -15,6 +15,8 @@ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
15
15
  import * as path from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { $which } from "@gajae-code/utils";
18
+ import { probeLinuxProcPidSync } from "../../gjc-runtime/linux-proc";
19
+ import { groupLeaderIdentityMatches } from "../../runtime/process-lifecycle";
18
20
 
19
21
  const HERE = path.dirname(fileURLToPath(import.meta.url));
20
22
  /** packages/coding-agent/vendor/insane-search */
@@ -119,22 +121,85 @@ function appendCapped(buffer: string, chunk: string, cap: number): string {
119
121
  return buffer + (chunk.length > remaining ? chunk.slice(0, remaining) : chunk);
120
122
  }
121
123
 
122
- /** Kill a child and its group, escalating to SIGKILL after a grace period. */
123
- function killChild(child: ChildProcess): void {
124
+ /**
125
+ * Signal the child's whole process group on POSIX (the engine is spawned as a
126
+ * group leader, so its node/playwright grandchildren — including headless
127
+ * Chromium — are covered), with an exact-child fallback on Windows.
128
+ */
129
+ type TeardownStatus = "terminated" | "still_running" | "identity_unverified";
130
+
131
+ function processGroupAlive(pgid: number): boolean {
124
132
  try {
125
- child.kill("SIGTERM");
126
- } catch {
127
- // already gone
133
+ process.kill(-pgid, 0);
134
+ return true;
135
+ } catch (error) {
136
+ return (error as NodeJS.ErrnoException).code === "EPERM";
137
+ }
138
+ }
139
+
140
+ async function pollUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
141
+ if (predicate()) return true;
142
+ const deadline = Date.now() + timeoutMs;
143
+ while (Date.now() < deadline) {
144
+ await Bun.sleep(Math.min(20, deadline - Date.now()));
145
+ if (predicate()) return true;
128
146
  }
129
- const timer = setTimeout(() => {
147
+ return predicate();
148
+ }
149
+
150
+ /** Idempotently terminate the exact child/process group and await bounded reaping. */
151
+ function createChildTeardown(child: ChildProcess, childExited: Promise<void>): () => Promise<TeardownStatus> {
152
+ const pgid = process.platform !== "win32" ? child.pid : undefined;
153
+ const leader = pgid && process.platform === "linux" ? probeLinuxProcPidSync(pgid) : undefined;
154
+ const leaderStartTime = leader?.kind === "live" ? leader.startTime : undefined;
155
+ let teardownPromise: Promise<TeardownStatus> | undefined;
156
+
157
+ const identityMatches = (): boolean => {
158
+ if (!pgid || process.platform !== "linux") return true;
159
+ return groupLeaderIdentityMatches(leaderStartTime, probeLinuxProcPidSync(pgid));
160
+ };
161
+ const signalGroup = (signal: "SIGTERM" | "SIGKILL"): boolean => {
162
+ if (!pgid || !identityMatches()) return false;
130
163
  try {
131
- child.kill("SIGKILL");
164
+ process.kill(-pgid, signal);
165
+ return true;
132
166
  } catch {
133
- // already gone
167
+ return !processGroupAlive(pgid);
134
168
  }
135
- }, KILL_GRACE_MS);
136
- timer.unref?.();
137
- child.once("exit", () => clearTimeout(timer));
169
+ };
170
+ const signalChild = (signal: "SIGTERM" | "SIGKILL"): void => {
171
+ try {
172
+ child.kill(signal);
173
+ } catch {
174
+ // Exact child is already gone.
175
+ }
176
+ };
177
+
178
+ return () => {
179
+ if (teardownPromise) return teardownPromise;
180
+ teardownPromise = (async (): Promise<TeardownStatus> => {
181
+ if (pgid) {
182
+ if (!processGroupAlive(pgid)) return "terminated";
183
+ if (!identityMatches()) return "identity_unverified";
184
+ signalGroup("SIGTERM");
185
+ if (await pollUntil(() => !processGroupAlive(pgid), KILL_GRACE_MS)) return "terminated";
186
+ if (!identityMatches()) return "identity_unverified";
187
+ signalGroup("SIGKILL");
188
+ return (await pollUntil(() => !processGroupAlive(pgid), KILL_GRACE_MS)) ? "terminated" : "still_running";
189
+ }
190
+
191
+ if (child.exitCode !== null || child.signalCode !== null) return "terminated";
192
+ signalChild("SIGTERM");
193
+ if ((await Promise.race([childExited.then(() => true), Bun.sleep(KILL_GRACE_MS).then(() => false)])) === true)
194
+ return "terminated";
195
+ signalChild("SIGKILL");
196
+ return (await Promise.race([childExited.then(() => true), Bun.sleep(KILL_GRACE_MS).then(() => false)])) ===
197
+ true
198
+ ? "terminated"
199
+ : "still_running";
200
+ })();
201
+ return teardownPromise;
202
+ };
138
203
  }
139
204
 
140
205
  /** Real engine runner: `python3 -m engine "<url>" --json`. */
@@ -149,31 +214,45 @@ export function runEngineSubprocess(
149
214
  let settled = false;
150
215
  let timedOut = false;
151
216
  let aborted = false;
217
+ let closeObserved = false;
218
+ let closeCode: number | null = null;
219
+ let teardownDone = false;
220
+ let teardownPromise: Promise<TeardownStatus> | undefined;
221
+ const { promise: childExited, resolve: resolveChildExited } = Promise.withResolvers<void>();
152
222
 
153
223
  const child = spawnImpl("python3", ["-m", "engine", inv.url, "--json"], {
154
224
  cwd: INSANE_VENDOR_DIR,
155
225
  env: { ...process.env, PYTHONPATH: INSANE_VENDOR_DIR },
156
226
  stdio: ["ignore", "pipe", "pipe"],
227
+ // Own process group on POSIX so killChild can signal the engine's
228
+ // whole tree (python3 -> node -> headless Chromium) in one shot.
229
+ detached: process.platform !== "win32",
157
230
  });
231
+ const teardown = createChildTeardown(child, childExited);
158
232
 
159
- const finish = (code: number | null): void => {
233
+ const finish = (): void => {
160
234
  if (settled) return;
235
+ if (teardownPromise ? !teardownDone : !closeObserved) return;
161
236
  settled = true;
162
237
  clearTimeout(timer);
163
238
  inv.signal?.removeEventListener("abort", onAbort);
164
- resolve({ code, stdout, stderr, timedOut, aborted });
239
+ resolve({ code: closeObserved ? closeCode : null, stdout, stderr, timedOut, aborted });
240
+ };
241
+ const requestStop = (reason: "timeout" | "abort"): void => {
242
+ if (teardownPromise) return;
243
+ if (reason === "timeout") timedOut = true;
244
+ else aborted = true;
245
+ teardownPromise = teardown();
246
+ void teardownPromise.finally(() => {
247
+ teardownDone = true;
248
+ finish();
249
+ });
165
250
  };
166
251
 
167
- const timer = setTimeout(() => {
168
- timedOut = true;
169
- killChild(child);
170
- }, inv.timeoutMs);
252
+ const timer = setTimeout(() => requestStop("timeout"), inv.timeoutMs);
171
253
  timer.unref?.();
172
254
 
173
- const onAbort = (): void => {
174
- aborted = true;
175
- killChild(child);
176
- };
255
+ const onAbort = (): void => requestStop("abort");
177
256
  if (inv.signal) {
178
257
  if (inv.signal.aborted) onAbort();
179
258
  else inv.signal.addEventListener("abort", onAbort, { once: true });
@@ -185,8 +264,19 @@ export function runEngineSubprocess(
185
264
  child.stderr?.on("data", (chunk: Buffer) => {
186
265
  stderr = appendCapped(stderr, chunk.toString("utf8"), MAX_STDERR_BYTES);
187
266
  });
188
- child.on("error", () => finish(null));
189
- child.on("close", code => finish(code));
267
+ child.once("exit", () => resolveChildExited());
268
+ child.on("error", () => {
269
+ resolveChildExited();
270
+ closeObserved = true;
271
+ closeCode = null;
272
+ finish();
273
+ });
274
+ child.on("close", code => {
275
+ resolveChildExited();
276
+ closeObserved = true;
277
+ closeCode = code;
278
+ finish();
279
+ });
190
280
  });
191
281
  }
192
282
 
@@ -9,16 +9,9 @@
9
9
 
10
10
  import { type AuthStorage, getEnvApiKey } from "@gajae-code/ai/core";
11
11
  import { $env, readSseJson } from "@gajae-code/utils";
12
- import type {
13
- PerplexityMessageOutput,
14
- PerplexityRequest,
15
- PerplexityResponse,
16
- SearchCitation,
17
- SearchResponse,
18
- SearchSource,
19
- } from "../../../web/search/types";
12
+ import type { PerplexityRequest, SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
20
13
  import { SearchProviderError } from "../../../web/search/types";
21
- import { dateToAgeSeconds } from "../utils";
14
+ import { clampNumResults, dateToAgeSeconds } from "../utils";
22
15
  import type { SearchParams } from "./base";
23
16
  import { SearchProvider } from "./base";
24
17
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -29,6 +22,8 @@ const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_
29
22
  const DEFAULT_MAX_TOKENS = 8192;
30
23
  const DEFAULT_TEMPERATURE = 0.2;
31
24
  const DEFAULT_NUM_SEARCH_RESULTS = 10;
25
+ const MIN_NUM_SEARCH_RESULTS = 3;
26
+ const MAX_NUM_SEARCH_RESULTS = 100;
32
27
  const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
33
28
  const OAUTH_API_VERSION = "2.18";
34
29
  const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
@@ -239,11 +234,7 @@ async function findPerplexityAuth(
239
234
  }
240
235
 
241
236
  /** Call Perplexity API-key endpoint. */
242
- async function callPerplexityApi(
243
- apiKey: string,
244
- request: PerplexityRequest,
245
- signal?: AbortSignal,
246
- ): Promise<PerplexityResponse> {
237
+ async function callPerplexityApi(apiKey: string, request: PerplexityRequest, signal?: AbortSignal): Promise<unknown> {
247
238
  const response = await fetch(PERPLEXITY_API_URL, {
248
239
  method: "POST",
249
240
  headers: {
@@ -258,14 +249,15 @@ async function callPerplexityApi(
258
249
  const errorText = await response.text();
259
250
  const classified = classifyProviderHttpError("perplexity", response.status, errorText);
260
251
  if (classified) throw classified;
261
- throw new SearchProviderError(
262
- "perplexity",
263
- `Perplexity API error (${response.status}): ${errorText}`,
264
- response.status,
265
- );
252
+ throw new SearchProviderError("perplexity", `Perplexity API error (${response.status})`, response.status);
266
253
  }
267
254
 
268
- return response.json() as Promise<PerplexityResponse>;
255
+ try {
256
+ return await response.json();
257
+ } catch (error) {
258
+ if (error instanceof Error && error.name === "AbortError") throw error;
259
+ throw new SearchProviderError("perplexity", "Perplexity API returned invalid JSON", 502);
260
+ }
269
261
  }
270
262
 
271
263
  function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] {
@@ -377,11 +369,7 @@ async function callPerplexityOAuth(
377
369
  const errorText = await response.text();
378
370
  const classified = classifyProviderHttpError("perplexity", response.status, errorText);
379
371
  if (classified) throw classified;
380
- throw new SearchProviderError(
381
- "perplexity",
382
- `Perplexity OAuth API error (${response.status}): ${errorText}`,
383
- response.status,
384
- );
372
+ throw new SearchProviderError("perplexity", `Perplexity OAuth API error (${response.status})`, response.status);
385
373
  }
386
374
 
387
375
  if (!response.body) {
@@ -426,22 +414,79 @@ async function callPerplexityOAuth(
426
414
  };
427
415
  }
428
416
 
429
- function messageContentToText(content: PerplexityMessageOutput["content"]): string {
430
- if (!content) return "";
417
+ type JsonObject = Record<string, unknown>;
418
+
419
+ function malformedResponse(detail: string): never {
420
+ throw new SearchProviderError("perplexity", `Perplexity API returned malformed response body (${detail})`, 502);
421
+ }
422
+
423
+ function isJsonObject(value: unknown): value is JsonObject {
424
+ return typeof value === "object" && value !== null && !Array.isArray(value);
425
+ }
426
+
427
+ function optionalArray(value: unknown, detail: string): unknown[] {
428
+ if (value === undefined || value === null) return [];
429
+ if (!Array.isArray(value)) malformedResponse(detail);
430
+ return value;
431
+ }
432
+
433
+ function messageContentToText(content: unknown): string {
434
+ if (content === undefined || content === null) return "";
431
435
  if (typeof content === "string") return content;
432
- return content.map(chunk => (chunk.type === "text" ? chunk.text : "")).join("");
436
+ if (!Array.isArray(content)) malformedResponse("message content must be a string or array");
437
+
438
+ return content
439
+ .map(chunk => {
440
+ if (!isJsonObject(chunk) || typeof chunk.type !== "string") {
441
+ malformedResponse("message content chunks must be objects with a type");
442
+ }
443
+ if (chunk.type !== "text") return "";
444
+ if (typeof chunk.text !== "string") malformedResponse("text content chunks must contain text");
445
+ return chunk.text;
446
+ })
447
+ .join("");
433
448
  }
434
449
 
435
450
  /** Parse API response into unified SearchResponse */
436
- function parseResponse(response: PerplexityResponse): SearchResponse {
437
- const messageContent = response.choices[0]?.message?.content ?? null;
451
+ function parseResponse(value: unknown): SearchResponse {
452
+ if (!isJsonObject(value)) malformedResponse("expected a JSON object");
453
+
454
+ const choices = optionalArray(value.choices, "choices must be an array");
455
+ const firstChoice = choices[0];
456
+ if (firstChoice !== undefined && !isJsonObject(firstChoice)) malformedResponse("choices must contain objects");
457
+ const message = firstChoice?.message;
458
+ if (message !== undefined && !isJsonObject(message)) malformedResponse("choice message must be an object");
459
+ const messageContent = message?.content;
438
460
  const answer = messageContentToText(messageContent);
439
461
 
440
462
  const sources: SearchSource[] = [];
441
463
  const citations: SearchCitation[] = [];
442
464
 
443
- const citationUrls = response.citations ?? [];
444
- const searchResults = response.search_results ?? [];
465
+ const citationUrls = optionalArray(value.citations, "citations must be an array").map(url => {
466
+ if (typeof url !== "string" || url.length === 0) malformedResponse("citations must contain non-empty URLs");
467
+ return url;
468
+ });
469
+ const searchResults = optionalArray(value.search_results, "search_results must be an array").map(result => {
470
+ if (!isJsonObject(result)) malformedResponse("search_results must contain objects");
471
+ if (typeof result.url !== "string" || result.url.length === 0) {
472
+ malformedResponse("search_results entries must contain a non-empty URL");
473
+ }
474
+ if (result.title !== undefined && typeof result.title !== "string") {
475
+ malformedResponse("search_results titles must be strings");
476
+ }
477
+ if (result.snippet !== undefined && typeof result.snippet !== "string") {
478
+ malformedResponse("search_results snippets must be strings");
479
+ }
480
+ if (result.date !== undefined && result.date !== null && typeof result.date !== "string") {
481
+ malformedResponse("search_results dates must be strings");
482
+ }
483
+ return {
484
+ title: result.title ?? result.url,
485
+ url: result.url,
486
+ snippet: result.snippet,
487
+ date: result.date,
488
+ };
489
+ });
445
490
 
446
491
  if (citationUrls.length > 0) {
447
492
  for (const url of citationUrls) {
@@ -475,18 +520,23 @@ function parseResponse(response: PerplexityResponse): SearchResponse {
475
520
  answer: answer || undefined,
476
521
  sources,
477
522
  citations: citations.length > 0 ? citations : undefined,
478
- usage: response.usage
523
+ usage: isJsonObject(value.usage)
479
524
  ? {
480
- inputTokens: response.usage.prompt_tokens,
481
- outputTokens: response.usage.completion_tokens,
482
- totalTokens: response.usage.total_tokens,
525
+ inputTokens: typeof value.usage.prompt_tokens === "number" ? value.usage.prompt_tokens : undefined,
526
+ outputTokens:
527
+ typeof value.usage.completion_tokens === "number" ? value.usage.completion_tokens : undefined,
528
+ totalTokens: typeof value.usage.total_tokens === "number" ? value.usage.total_tokens : undefined,
483
529
  }
484
530
  : undefined,
485
- model: response.model,
486
- requestId: response.id,
531
+ model: typeof value.model === "string" ? value.model : undefined,
532
+ requestId: typeof value.id === "string" ? value.id : undefined,
487
533
  };
488
534
  }
489
535
 
536
+ function normalizeNumSearchResults(value: number | undefined): number {
537
+ return Math.max(MIN_NUM_SEARCH_RESULTS, clampNumResults(value, DEFAULT_NUM_SEARCH_RESULTS, MAX_NUM_SEARCH_RESULTS));
538
+ }
539
+
490
540
  function applySourceLimit(result: SearchResponse, limit?: number): SearchResponse {
491
541
  if (limit && result.sources.length > limit) {
492
542
  result.sources = result.sources.slice(0, limit);
@@ -503,6 +553,9 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
503
553
 
504
554
  if (auth.type === "oauth" || auth.type === "cookies") {
505
555
  const oauthResult = await callPerplexityOAuth(auth, params);
556
+ if (!oauthResult.answer.trim()) {
557
+ throw new SearchProviderError("perplexity", "Perplexity web search returned no answer", 424);
558
+ }
506
559
  return applySourceLimit(
507
560
  {
508
561
  provider: "perplexity",
@@ -529,7 +582,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
529
582
  max_tokens: params.max_tokens ?? DEFAULT_MAX_TOKENS,
530
583
  temperature: params.temperature ?? DEFAULT_TEMPERATURE,
531
584
  search_mode: "web",
532
- num_search_results: params.num_search_results ?? DEFAULT_NUM_SEARCH_RESULTS,
585
+ num_search_results: normalizeNumSearchResults(params.num_search_results),
533
586
  web_search_options: {
534
587
  search_type: "pro",
535
588
  search_context_size: "medium",
@@ -543,8 +596,16 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
543
596
  request.search_recency_filter = params.search_recency_filter;
544
597
  }
545
598
 
546
- const response = await callPerplexityApi(auth.token, request, params.signal);
547
- const result = parseResponse(response);
599
+ let result: SearchResponse | undefined;
600
+ for (let attempt = 0; attempt < 2; attempt++) {
601
+ const response = await callPerplexityApi(auth.token, request, params.signal);
602
+ result = parseResponse(response);
603
+ if (result.answer?.trim()) break;
604
+ }
605
+
606
+ if (!result?.answer?.trim()) {
607
+ throw new SearchProviderError("perplexity", "Perplexity web search returned no answer", 424);
608
+ }
548
609
  result.authMode = "api_key";
549
610
  return applySourceLimit(result, params.num_results);
550
611
  }