specrails-desktop 2.2.0 → 2.3.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 (63) hide show
  1. package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
  2. package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
  3. package/client/dist/assets/{AnalyticsPage-D6LE6wG2.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
  4. package/client/dist/assets/{BarChart-B366kDEj.js → BarChart-CMdLa6Es.js} +2 -2
  5. package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-DG5LA_WO.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-ChQ1oXLC.js → DocsDialog-D8yoyZDD.js} +2 -2
  8. package/client/dist/assets/{DocsPage-BfGH8NUf.js → DocsPage-CeO-fAxy.js} +2 -2
  9. package/client/dist/assets/{ExportDropdown-9tRrlfM7.js → ExportDropdown-DuoZcdYN.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-DANIzihd.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
  11. package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
  12. package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
  13. package/client/dist/assets/code-BtsmPQLV.js +1 -0
  14. package/client/dist/assets/code-CY85RXZU.js +1 -0
  15. package/client/dist/assets/code-Coa8f2Sh.js +1 -0
  16. package/client/dist/assets/code-D1z-YDt-.js +1 -0
  17. package/client/dist/assets/code-DDU0CRS0.js +1 -0
  18. package/client/dist/assets/code-L35Loak_.js +1 -0
  19. package/client/dist/assets/code-g0qFMzyg.js +1 -0
  20. package/client/dist/assets/code-zCwBt3Uu.js +1 -0
  21. package/client/dist/assets/{dist-js-BvQ52Q67.js → dist-js-4UEGaKhD.js} +1 -1
  22. package/client/dist/assets/{dist-js-XEilFTNz.js → dist-js-H6hyhSuv.js} +1 -1
  23. package/client/dist/assets/{index-CNiaj7Sj.js → index-CGHKpC-N.js} +13 -13
  24. package/client/dist/assets/index-D17R4Cjc.css +2 -0
  25. package/client/dist/assets/{lib-DZJmnErt.js → lib-Cs5FrUJI.js} +1 -1
  26. package/client/dist/assets/{useProjectCache-H0T8Ot9j.js → useProjectCache-BZWYV-w-.js} +1 -1
  27. package/client/dist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/server/dist/agent-refine-manager.js +128 -153
  30. package/server/dist/chat-manager.js +246 -0
  31. package/server/dist/code-explorer-router.js +78 -0
  32. package/server/dist/command-resolver.js +17 -0
  33. package/server/dist/contract-refine-runner.js +42 -10
  34. package/server/dist/db.js +6 -0
  35. package/server/dist/desktop-db.js +3 -0
  36. package/server/dist/explore-stdin-session.js +129 -0
  37. package/server/dist/mobile/mobile-auth.js +16 -0
  38. package/server/dist/project-router-chat.js +218 -0
  39. package/server/dist/project-router-helpers.js +275 -0
  40. package/server/dist/project-router-jobs.js +389 -0
  41. package/server/dist/project-router-settings.js +312 -0
  42. package/server/dist/project-router-setup.js +456 -0
  43. package/server/dist/project-router-spending.js +320 -0
  44. package/server/dist/project-router-terminals.js +312 -0
  45. package/server/dist/project-router-tickets.js +1767 -0
  46. package/server/dist/project-router.js +27 -3943
  47. package/server/dist/providers/claude-adapter.js +58 -17
  48. package/server/dist/providers/codex-adapter.js +6 -0
  49. package/server/dist/spawn-lifecycle.js +117 -0
  50. package/client/dist/assets/ActivityFeedPage-BupGdGjj.js +0 -1
  51. package/client/dist/assets/AgentsPage-F3xksiLd.js +0 -86
  52. package/client/dist/assets/CodePage-DLwCJgQ0.js +0 -2
  53. package/client/dist/assets/JobDetailPage-1RtejIOB.js +0 -16
  54. package/client/dist/assets/JobsPage-NuDf5Zbx.js +0 -1
  55. package/client/dist/assets/code-AL1rVIMb.js +0 -1
  56. package/client/dist/assets/code-C0BKpkht.js +0 -1
  57. package/client/dist/assets/code-C0FTS3ew.js +0 -1
  58. package/client/dist/assets/code-CPcHxzxw.js +0 -1
  59. package/client/dist/assets/code-D3ryDniw.js +0 -1
  60. package/client/dist/assets/code-D3zVVQTj.js +0 -1
  61. package/client/dist/assets/code-PCmfS3dn.js +0 -1
  62. package/client/dist/assets/code-exI0G5Wd.js +0 -1
  63. package/client/dist/assets/index-DgFfrrTX.css +0 -2
@@ -20,6 +20,7 @@ const providers_1 = require("./providers");
20
20
  const context_scope_1 = require("./context-scope");
21
21
  const user_mcp_config_1 = require("./user-mcp-config");
22
22
  const binary_probe_1 = require("./binary-probe");
23
+ const explore_stdin_session_1 = require("./explore-stdin-session");
23
24
  const COMMAND_INSTRUCTION = 'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
24
25
  ':::command\n/specrails:implement #42\n::: ' +
25
26
  'The user will be prompted to confirm before the command runs.';
@@ -60,6 +61,9 @@ class ChatManager {
60
61
  _exploreLifecycle;
61
62
  /** FIFO queue of Explore turns waiting for a concurrency slot. */
62
63
  _exploreQueue;
64
+ /** Persistent-stdin Explore transport (big bet #3, flag-gated default OFF).
65
+ * Holds long-lived claude children that survive between turns. */
66
+ _stdinSessions = new explore_stdin_session_1.ExploreStdinSessions();
63
67
  _cwd;
64
68
  _projectName;
65
69
  _adapter;
@@ -142,6 +146,9 @@ class ChatManager {
142
146
  }
143
147
  catch { /* best-effort */ }
144
148
  }
149
+ // Persistent-stdin children live OUTSIDE _activeProcesses between turns —
150
+ // idle-kill must reach them too (the next turn re-spawns with --resume).
151
+ this._stdinSessions.kill(conversationId);
145
152
  }, exports.EXPLORE_IDLE_KILL_MS);
146
153
  }
147
154
  /**
@@ -223,6 +230,8 @@ class ChatManager {
223
230
  }
224
231
  catch { /* best-effort */ }
225
232
  }
233
+ // Reclaim the slot from a persistent-stdin child parked between turns.
234
+ this._stdinSessions.kill(victim);
226
235
  this._clearIdleTimer(victim);
227
236
  this._exploreLifecycle.delete(victim);
228
237
  if (this._countStreamingExplore() < exports.EXPLORE_MAX_CONCURRENCY)
@@ -535,6 +544,10 @@ class ChatManager {
535
544
  sessionId: conversation.session_id ?? undefined,
536
545
  maxTurns: options?.maxTurns,
537
546
  extraArgs: scopeFlags,
547
+ // "My approved MCPs" (scope.userMcp) loads the developer's user-scope,
548
+ // plugin, and connector MCP servers — which require the `user` setting
549
+ // source. Claude-only (codex reads ~/.codex natively, ignores this).
550
+ loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
538
551
  });
539
552
  if (conversationScope) {
540
553
  console.log(`[chat-manager] scope=${JSON.stringify(conversationScope)} flags=${scopeFlags.join(' ')} promptBytes=${Buffer.byteLength(systemPrompt)}`);
@@ -543,6 +556,19 @@ class ChatManager {
543
556
  // not pipeline jobs. Telemetry is scoped to QueueManager pipeline runs only.
544
557
  // spawnAiCli reroutes multi-line argv values through stdin on Windows.
545
558
  const spawnCwd = this._resolveSpawnCwd(conversation.kind, conversationScope, adapter.id);
559
+ // Big bet #3 fast-path: persistent-stdin multi-turn for Explore (claude
560
+ // only, flag-gated default OFF). Reuses a single long-lived child across
561
+ // turns so turns 2+ skip spawn + session rehydration. Full fallback to the
562
+ // legacy spawn-per-turn path below when disabled / unsupported.
563
+ if ((0, explore_stdin_session_1.isExplorePersistentStdinEnabled)() &&
564
+ conversation.kind === 'explore' &&
565
+ adapter.capabilities.persistentStdin === true) {
566
+ return await this._streamPersistentExploreTurn({
567
+ conversationId, conversation, adapter, binary, model, systemPrompt,
568
+ scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText,
569
+ lightweight, conversationScope,
570
+ });
571
+ }
546
572
  const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
547
573
  env: process.env,
548
574
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -862,6 +888,223 @@ class ChatManager {
862
888
  this._reservedTurns.delete(conversationId);
863
889
  }
864
890
  }
891
+ /**
892
+ * Persistent-stdin Explore turn (big bet #3). Reuses one long-lived claude
893
+ * child per conversation via `--input-format stream-json`: the user turn is
894
+ * written to the child's stdin, and the turn ends on the `result` event
895
+ * (NOT process close — the child stays alive for the next turn). Mirrors the
896
+ * legacy close-handler's finalisation (spec-draft parse, persist, session
897
+ * capture, chat_done, invocation accounting, lifecycle) without crash-respawn
898
+ * — a dead persistent child is evicted and the next turn re-spawns with
899
+ * `--resume`. Only reached when the flag is on; the legacy path is untouched.
900
+ */
901
+ async _streamPersistentExploreTurn(p) {
902
+ const { conversationId, conversation, adapter, binary, model, systemPrompt, scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText, lightweight, conversationScope, } = p;
903
+ const sessionArgs = adapter.buildArgs('chat-stream', {
904
+ prompt: '',
905
+ systemPrompt,
906
+ model,
907
+ sessionId: conversation.session_id ?? undefined,
908
+ extraArgs: scopeFlags,
909
+ loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
910
+ });
911
+ const { child } = this._stdinSessions.getOrSpawn(conversationId, {
912
+ binary, args: sessionArgs, cwd: spawnCwd, env: process.env,
913
+ });
914
+ this._activeProcesses.set(conversationId, child);
915
+ this._buffers.set(conversationId, '');
916
+ this._emittedProposals.set(conversationId, new Set());
917
+ this._streamFilters.set(conversationId, { inBlock: false, pendingTail: '' });
918
+ const adapterEvents = [];
919
+ let capturedSessionId = null;
920
+ let stderrBuf = '';
921
+ const turnStartedAt = new Date().toISOString();
922
+ const emitDelta = (newText) => {
923
+ const prev = this._buffers.get(conversationId) ?? '';
924
+ this._buffers.set(conversationId, prev + newText);
925
+ const filter = this._streamFilters.get(conversationId);
926
+ const visibleDelta = filter ? filterDraftBlocksLive(filter, newText) : newText;
927
+ if (visibleDelta) {
928
+ this._broadcast({
929
+ type: 'chat_stream',
930
+ conversationId,
931
+ delta: visibleDelta,
932
+ timestamp: new Date().toISOString(),
933
+ });
934
+ }
935
+ const proposals = extractCommandProposals(this._buffers.get(conversationId) ?? '');
936
+ const emitted = this._emittedProposals.get(conversationId);
937
+ if (emitted) {
938
+ for (const proposal of proposals) {
939
+ if (!emitted.has(proposal)) {
940
+ emitted.add(proposal);
941
+ this._broadcast({
942
+ type: 'chat_command_proposal',
943
+ conversationId,
944
+ command: proposal,
945
+ timestamp: new Date().toISOString(),
946
+ });
947
+ }
948
+ }
949
+ }
950
+ };
951
+ const recordInv = (status) => {
952
+ if (!this._projectId)
953
+ return;
954
+ try {
955
+ const { result, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, {
956
+ fallbackModel: model,
957
+ });
958
+ (0, ai_invocations_1.recordInvocation)(this._db, {
959
+ id: (0, crypto_1.randomUUID)(),
960
+ project_id: this._projectId,
961
+ provider: adapter.id,
962
+ surface: 'explore-spec',
963
+ surface_ref_id: conversationId,
964
+ conversation_id: conversationId,
965
+ status,
966
+ started_at: turnStartedAt,
967
+ finished_at: new Date().toISOString(),
968
+ total_cost_usd_estimated: estimated,
969
+ ...result,
970
+ });
971
+ this._broadcast({ type: 'spending.invalidated', projectId: this._projectId });
972
+ }
973
+ catch (err) {
974
+ console.error('[chat-manager] recordInvocation failed:', err);
975
+ }
976
+ };
977
+ const cleanupTurnState = () => {
978
+ this._activeProcesses.delete(conversationId);
979
+ this._buffers.delete(conversationId);
980
+ this._emittedProposals.delete(conversationId);
981
+ this._streamFilters.delete(conversationId);
982
+ };
983
+ const markStreamingEnded = (success) => {
984
+ const life = this._exploreLifecycle.get(conversationId);
985
+ if (life) {
986
+ life.isStreaming = false;
987
+ life.lastActivityAt = Date.now();
988
+ if (success)
989
+ life.crashCount = 0;
990
+ if (life.isMinimized)
991
+ this._startIdleTimer(conversationId);
992
+ }
993
+ this._drainExploreQueue();
994
+ };
995
+ return new Promise((resolve) => {
996
+ let settled = false;
997
+ const finishTurn = () => {
998
+ if (settled)
999
+ return;
1000
+ settled = true;
1001
+ this._stdinSessions.clearHandlers(conversationId);
1002
+ const fullText = this._buffers.get(conversationId) ?? '';
1003
+ const wasAborting = this._abortingConversations.has(conversationId);
1004
+ cleanupTurnState();
1005
+ this._abortingConversations.delete(conversationId);
1006
+ markStreamingEnded(true);
1007
+ recordInv(wasAborting ? 'aborted' : 'success');
1008
+ const parsed = (0, spec_draft_parser_1.parseSpecDraftBlocks)(fullText);
1009
+ const persistedText = parsed.blocks.length > 0 ? parsed.stripped : fullText;
1010
+ if (parsed.blocks.length > 0) {
1011
+ const prevState = this._specDraftStates.get(conversationId);
1012
+ const nextState = (0, spec_draft_parser_1.applyBlocks)(prevState, parsed.blocks);
1013
+ this._specDraftStates.set(conversationId, nextState);
1014
+ this._broadcast({
1015
+ type: 'spec_draft.update',
1016
+ conversationId,
1017
+ draft: nextState.draft,
1018
+ ready: nextState.ready,
1019
+ chips: nextState.chips,
1020
+ changedFields: nextState.lastChangedFields,
1021
+ timestamp: new Date().toISOString(),
1022
+ });
1023
+ }
1024
+ if (persistedText) {
1025
+ (0, db_1.addMessage)(this._db, { conversation_id: conversationId, role: 'assistant', content: persistedText });
1026
+ }
1027
+ if (capturedSessionId) {
1028
+ (0, db_1.updateConversation)(this._db, conversationId, { session_id: capturedSessionId });
1029
+ }
1030
+ this._broadcast({
1031
+ type: 'chat_done',
1032
+ conversationId,
1033
+ fullText: persistedText,
1034
+ timestamp: new Date().toISOString(),
1035
+ });
1036
+ if (isFirstTurn && fullText && !lightweight) {
1037
+ this._autoTitle(conversationId, userText, fullText);
1038
+ }
1039
+ resolve();
1040
+ };
1041
+ const onClose = (code) => {
1042
+ // The persistent child died (crash / idle-kill / shutdown). If the turn
1043
+ // already finished on its `result` event, ignore. No crash-respawn —
1044
+ // the session is evicted by the transport; the next turn re-spawns with
1045
+ // `--resume`, so no persisted state is lost.
1046
+ if (settled)
1047
+ return;
1048
+ settled = true;
1049
+ this._stdinSessions.clearHandlers(conversationId);
1050
+ const wasAborting = this._abortingConversations.has(conversationId);
1051
+ cleanupTurnState();
1052
+ this._abortingConversations.delete(conversationId);
1053
+ markStreamingEnded(false);
1054
+ recordInv(wasAborting ? 'aborted' : 'failed');
1055
+ if (wasAborting) {
1056
+ resolve();
1057
+ return;
1058
+ }
1059
+ const stderrTail = stderrBuf.trim().slice(-500);
1060
+ this._broadcast({
1061
+ type: 'chat_error',
1062
+ conversationId,
1063
+ error: stderrTail
1064
+ ? `${binary} exited with code ${code ?? 'unknown'}: ${stderrTail}`
1065
+ : `Process exited with code ${code ?? 'unknown'}`,
1066
+ timestamp: new Date().toISOString(),
1067
+ });
1068
+ resolve();
1069
+ };
1070
+ const onLine = (line) => {
1071
+ const ev = adapter.parseStreamLine(line);
1072
+ if (!ev)
1073
+ return;
1074
+ adapterEvents.push(ev);
1075
+ switch (ev.kind) {
1076
+ case 'text-delta':
1077
+ emitDelta(ev.text);
1078
+ break;
1079
+ case 'session-started':
1080
+ if (ev.sessionId)
1081
+ capturedSessionId = ev.sessionId;
1082
+ break;
1083
+ case 'result': {
1084
+ const sid = ev.payload.session_id;
1085
+ if (sid)
1086
+ capturedSessionId = sid;
1087
+ finishTurn();
1088
+ break;
1089
+ }
1090
+ default:
1091
+ break;
1092
+ }
1093
+ };
1094
+ this._stdinSessions.setHandlers(conversationId, {
1095
+ onLine,
1096
+ onStderr: (s) => {
1097
+ stderrBuf += s;
1098
+ console.error(`[chat-manager] ${binary} stderr (${conversationId}):`, s.trim());
1099
+ },
1100
+ onClose,
1101
+ });
1102
+ if (!this._stdinSessions.writeTurn(conversationId, promptForAdapter)) {
1103
+ // stdin already gone (child died between spawn and write) — fail the turn.
1104
+ onClose(null);
1105
+ }
1106
+ });
1107
+ }
865
1108
  abort(conversationId) {
866
1109
  const child = this._activeProcesses.get(conversationId);
867
1110
  if (!child || !child.pid)
@@ -889,6 +1132,7 @@ class ChatManager {
889
1132
  clearTimeout(this._exploreQueue[idx].timeoutTimer);
890
1133
  this._exploreQueue.splice(idx, 1);
891
1134
  }
1135
+ this._stdinSessions.kill(conversationId);
892
1136
  this._exploreLifecycle.delete(conversationId);
893
1137
  }
894
1138
  /**
@@ -907,6 +1151,8 @@ class ChatManager {
907
1151
  catch { /* best-effort */ }
908
1152
  }
909
1153
  }
1154
+ // Persistent-stdin children outlive individual turns — tear them down too.
1155
+ this._stdinSessions.killAll();
910
1156
  for (const id of this._exploreLifecycle.keys()) {
911
1157
  this._clearIdleTimer(id);
912
1158
  }
@@ -539,6 +539,84 @@ function createCodeExplorerRouter(deps) {
539
539
  absolutePath: abs,
540
540
  });
541
541
  });
542
+ // In-app editing (v1): overwrite an existing text file with new content.
543
+ // Same guards as the read path — traversal, deny-list, gitignore, size, and
544
+ // binary refusal — so the editor can never write outside the tree, clobber a
545
+ // secret/lockfile, or corrupt a binary. Creating new files / renames is out of
546
+ // scope here. After a write the existing hash-gated `summaryStale` flag makes
547
+ // the next GET /file surface the summary as stale (regenerate via the existing
548
+ // POST /file/regenerate-summary).
549
+ router.put('/file', (req, res) => {
550
+ const body = (req.body ?? {});
551
+ const relRaw = typeof body.path === 'string' ? body.path : undefined;
552
+ const content = typeof body.content === 'string' ? body.content : undefined;
553
+ if (!relRaw || content === undefined) {
554
+ res.status(400).json({ error: 'path and content are required' });
555
+ return;
556
+ }
557
+ const guard = resolveSafePath(deps.projectPath, relRaw);
558
+ if (!guard) {
559
+ res.status(400).json({ error: 'path traversal not allowed' });
560
+ return;
561
+ }
562
+ if (isDeniedRelPath(relRaw)) {
563
+ res.status(403).json({ error: 'path is excluded by the code-explorer deny-list' });
564
+ return;
565
+ }
566
+ const rel = normalizeRel(relRaw);
567
+ if (isGitIgnored(deps.projectPath, rel)) {
568
+ res.status(403).json({ error: 'path is gitignored' });
569
+ return;
570
+ }
571
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
572
+ res.status(413).json({ error: 'file too large' });
573
+ return;
574
+ }
575
+ if (/[\x00-\x08\x0e-\x1f]/.test(content)) {
576
+ res.status(415).json({ error: 'binary content not allowed' });
577
+ return;
578
+ }
579
+ const abs = guard;
580
+ let stat;
581
+ try {
582
+ stat = fs_1.default.statSync(abs);
583
+ }
584
+ catch {
585
+ res.status(404).json({ error: 'file not found (in-app editing only overwrites existing files)' });
586
+ return;
587
+ }
588
+ if (!stat.isFile()) {
589
+ res.status(400).json({ error: 'path is not a regular file' });
590
+ return;
591
+ }
592
+ // Refuse to overwrite a binary file as text (would corrupt it).
593
+ try {
594
+ const fd = fs_1.default.openSync(abs, 'r');
595
+ try {
596
+ const head = Buffer.alloc(Math.min(BINARY_PROBE_BYTES, stat.size));
597
+ fs_1.default.readSync(fd, head, 0, head.length, 0);
598
+ if (head.includes(0)) {
599
+ res.status(415).json({ error: 'cannot edit a binary file' });
600
+ return;
601
+ }
602
+ }
603
+ finally {
604
+ fs_1.default.closeSync(fd);
605
+ }
606
+ }
607
+ catch {
608
+ res.status(500).json({ error: 'failed to read file' });
609
+ return;
610
+ }
611
+ try {
612
+ fs_1.default.writeFileSync(abs, content, 'utf8');
613
+ }
614
+ catch {
615
+ res.status(500).json({ error: 'failed to write file' });
616
+ return;
617
+ }
618
+ res.json({ ok: true, bytes: Buffer.byteLength(content, 'utf8'), path: rel });
619
+ });
542
620
  router.get('/summary', async (req, res) => {
543
621
  const relRaw = req.query.path;
544
622
  if (!relRaw || typeof relRaw !== 'string') {
@@ -53,6 +53,16 @@ The user's idea follows below. Begin the Explore Spec conversation.
53
53
 
54
54
  ${commandArgs}`.trim();
55
55
  }
56
+ /**
57
+ * A `:`-separated command segment is safe iff it is a plain identifier: no path
58
+ * separators, no `.`/`..`, no NUL. Anything else could escape the
59
+ * commands/skills directory once joined into the lookup path.
60
+ */
61
+ function isSafeSegment(seg) {
62
+ if (seg.length === 0 || seg === '.' || seg === '..')
63
+ return false;
64
+ return !/[/\\\0]/.test(seg);
65
+ }
56
66
  /**
57
67
  * Try to find a command/skill .md file for the given command path parts
58
68
  * within the given base directory. Returns the resolved path or null.
@@ -87,6 +97,13 @@ function resolveCommand(command, cwd) {
87
97
  const builtIn = builtInCommand(commandPath, commandArgs);
88
98
  if (builtIn)
89
99
  return builtIn;
100
+ // Path-traversal guard: each segment is joined verbatim into
101
+ // `<baseDir>/.claude/commands/<...parts>.md`, so a segment like `..`, one
102
+ // containing a path separator, or an absolute fragment would escape the
103
+ // commands/skills directory and read an arbitrary file. Reject and leave the
104
+ // command unresolved (defense-in-depth even though the input is user-typed).
105
+ if (!parts.every(isSafeSegment))
106
+ return command;
90
107
  // 1. Check the project directory
91
108
  let resolvedPath = findCommandFile(cwd, parts);
92
109
  // 2. Fallback: check the app's own directory
@@ -19,6 +19,8 @@ exports.runContractRefineForQuick = runContractRefineForQuick;
19
19
  const node_crypto_1 = require("node:crypto");
20
20
  const node_readline_1 = require("node:readline");
21
21
  const cli_prompt_1 = require("./util/cli-prompt");
22
+ const spawn_lifecycle_1 = require("./spawn-lifecycle");
23
+ const registry_1 = require("./providers/registry");
22
24
  const explore_contract_refine_1 = require("./explore-contract-refine");
23
25
  const db_1 = require("./db");
24
26
  const explore_cwd_manager_1 = require("./explore-cwd-manager");
@@ -242,15 +244,45 @@ async function runContractRefine(deps, conversationId, ticketId) {
242
244
  projectName: deps.projectName,
243
245
  }, conversation);
244
246
  const startedAt = now().toISOString();
245
- let child;
246
- try {
247
- child = spawn('claude', args, {
248
- env: process.env,
249
- stdio: ['ignore', 'pipe', 'pipe'],
250
- cwd,
251
- });
252
- }
253
- catch (err) {
247
+ // Spawn/stream/timeout/settlement is owned by the shared spawn-lifecycle; the
248
+ // contract-refine-specific raw parse (fullText from assistant text blocks,
249
+ // the raw result event) and ALL finalize/record/broadcast logic stay here,
250
+ // byte-for-byte, so behaviour is unchanged (it still records via the legacy
251
+ // recordSafely path).
252
+ let fullText = '';
253
+ let resultEvent = null;
254
+ const run = await (0, spawn_lifecycle_1.runAiCliInvocation)({
255
+ adapter: (0, registry_1.getAdapter)('claude'),
256
+ binary: 'claude',
257
+ argv: args,
258
+ cwd,
259
+ env: process.env,
260
+ spawn,
261
+ timeoutMs,
262
+ onStdoutLine: (line) => {
263
+ let parsed = null;
264
+ try {
265
+ parsed = JSON.parse(line);
266
+ }
267
+ catch {
268
+ return;
269
+ }
270
+ if (!parsed)
271
+ return;
272
+ const type = parsed.type;
273
+ if (type === 'result') {
274
+ resultEvent = parsed;
275
+ }
276
+ else if (type === 'assistant') {
277
+ const message = parsed.message;
278
+ for (const b of (message?.content ?? [])) {
279
+ if (b.type === 'text' && typeof b.text === 'string')
280
+ fullText += b.text;
281
+ }
282
+ }
283
+ },
284
+ });
285
+ if (run.spawnFailed) {
254
286
  recordSafely(deps, conversationId, ticketId, conversation.model, startedAt, now().toISOString(), 'failed', null);
255
287
  deps.broadcast({
256
288
  type: 'explore.contract_refine_failed',
@@ -261,7 +293,7 @@ async function runContractRefine(deps, conversationId, ticketId) {
261
293
  });
262
294
  return { ok: false, reason: 'crashed', ticketId, conversationId };
263
295
  }
264
- const result = await readRefineChildOutput(child, timeoutMs);
296
+ const result = { fullText, resultEvent, code: run.code, timedOut: run.timedOut };
265
297
  const finishedAt = now().toISOString();
266
298
  console.log(`[contract-refine-runner] spawn done code=${result.code} timedOut=${result.timedOut} hasResult=${!!result.resultEvent} textBytes=${result.fullText.length}`);
267
299
  if (result.timedOut) {
package/server/dist/db.js CHANGED
@@ -600,6 +600,12 @@ function initDb(dbPath) {
600
600
  const db = new better_sqlite3_1.default(dbPath);
601
601
  db.pragma('journal_mode = WAL');
602
602
  db.pragma('foreign_keys = ON');
603
+ // Under load, QueueManager / ChatManager / FileSummaryManager write the same
604
+ // per-project DB concurrently with /analytics reads. Wait up to 5s on a lock
605
+ // instead of throwing SQLITE_BUSY, and cap the WAL so a long checkpoint can't
606
+ // grow it without bound.
607
+ db.pragma('busy_timeout = 5000');
608
+ db.pragma('journal_size_limit = 10000000'); // ~10 MB
603
609
  applyMigrations(db);
604
610
  // H-13: restrict the db + its WAL sidecars to 0600 (jobs.sqlite holds chat
605
611
  // transcripts and verbatim terminal command history). After migrations the
@@ -315,6 +315,9 @@ function initDesktopDb(dbPath = getDesktopDbPath()) {
315
315
  const db = new better_sqlite3_1.default(dbPath);
316
316
  db.pragma('journal_mode = WAL');
317
317
  db.pragma('foreign_keys = ON');
318
+ // Wait up to 5s on a lock instead of throwing SQLITE_BUSY, and cap the WAL.
319
+ db.pragma('busy_timeout = 5000');
320
+ db.pragma('journal_size_limit = 10000000'); // ~10 MB
318
321
  applyDesktopMigrations(db);
319
322
  // H-13: desktop.sqlite stores webhook HMAC secrets in plaintext — restrict it
320
323
  // (and its WAL sidecars) to owner read/write.
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ // Persistent-stdin Explore transport (big bet #3).
3
+ //
4
+ // The default Explore turn spawns a fresh `claude` child per message (turns 2+
5
+ // pay `--resume` session-rehydration latency). This module keeps ONE child
6
+ // alive per conversation using claude's `--input-format stream-json` multi-turn
7
+ // transport: each user turn is written as a newline-delimited JSON message to
8
+ // the child's stdin, and the same long-lived child streams the response. The
9
+ // child stays resident between turns, so turns 2+ skip spawn + rehydration.
10
+ //
11
+ // Transport only — it owns spawning, the persistent stdout/stderr fan-out, and
12
+ // stdin framing. ChatManager drives per-turn rendering, persistence, and
13
+ // lifecycle through the handler hooks. Default OFF behind a flag; claude-only
14
+ // (gated by capabilities.persistentStdin); full fallback to the legacy
15
+ // spawn-per-turn path means zero behaviour change when disabled.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.ExploreStdinSessions = void 0;
18
+ exports.isExplorePersistentStdinEnabled = isExplorePersistentStdinEnabled;
19
+ exports.frameStreamJsonUserMessage = frameStreamJsonUserMessage;
20
+ const node_readline_1 = require("node:readline");
21
+ const cli_prompt_1 = require("./util/cli-prompt");
22
+ /**
23
+ * Persistent-stdin Explore is opt-in. Default OFF — set
24
+ * `SPECRAILS_EXPLORE_PERSISTENT_STDIN=1` to enable. Any other value (or unset)
25
+ * keeps the legacy spawn-per-turn path, so this is also the escape hatch.
26
+ */
27
+ function isExplorePersistentStdinEnabled() {
28
+ return process.env.SPECRAILS_EXPLORE_PERSISTENT_STDIN === '1';
29
+ }
30
+ /**
31
+ * Frame one user turn as a stream-json input line for claude's
32
+ * `--input-format stream-json` transport. Newline-terminated so the child reads
33
+ * exactly one message per turn. Content is sent as a plain string (claude
34
+ * accepts string content for user messages).
35
+ */
36
+ function frameStreamJsonUserMessage(text) {
37
+ return JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n';
38
+ }
39
+ /**
40
+ * Owns the long-lived claude children for persistent-stdin Explore. Keyed by
41
+ * conversation id. One child per conversation; a single stdout reader fans each
42
+ * line out to the conversation's currently-installed turn handler.
43
+ */
44
+ class ExploreStdinSessions {
45
+ _sessions = new Map();
46
+ has(id) {
47
+ return this._sessions.has(id);
48
+ }
49
+ size() {
50
+ return this._sessions.size;
51
+ }
52
+ /**
53
+ * Return the live child for a conversation, spawning one in persistent mode
54
+ * if none exists. `isNew` is true only when a child was just spawned (the
55
+ * caller writes the first turn either way).
56
+ */
57
+ getOrSpawn(id, spec) {
58
+ const existing = this._sessions.get(id);
59
+ if (existing && existing.child.pid && !existing.child.killed) {
60
+ return { child: existing.child, isNew: false };
61
+ }
62
+ const spawn = spec.spawn ?? cli_prompt_1.spawnAiCli;
63
+ const child = spawn(spec.binary, spec.args, {
64
+ env: spec.env ?? process.env,
65
+ // stdin MUST be piped — it is the per-turn transport.
66
+ stdio: ['pipe', 'pipe', 'pipe'],
67
+ cwd: spec.cwd,
68
+ });
69
+ const session = { child, reader: null, handlers: null };
70
+ if (child.stdout) {
71
+ session.reader = (0, node_readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
72
+ session.reader.on('line', (line) => session.handlers?.onLine(line));
73
+ }
74
+ if (child.stderr) {
75
+ child.stderr.on('data', (chunk) => session.handlers?.onStderr(chunk.toString()));
76
+ }
77
+ const onExit = (code) => {
78
+ // Evict first so a handler that re-spawns inside onClose sees a clean slot.
79
+ if (this._sessions.get(id) === session)
80
+ this._sessions.delete(id);
81
+ session.handlers?.onClose(code);
82
+ };
83
+ child.on('close', onExit);
84
+ child.on('error', () => onExit(null));
85
+ this._sessions.set(id, session);
86
+ return { child, isNew: true };
87
+ }
88
+ /** Install the handlers for the current turn (replaces any prior turn's). */
89
+ setHandlers(id, handlers) {
90
+ const s = this._sessions.get(id);
91
+ if (s)
92
+ s.handlers = handlers;
93
+ }
94
+ /** Detach the current turn's handlers (between turns stray lines are dropped). */
95
+ clearHandlers(id) {
96
+ const s = this._sessions.get(id);
97
+ if (s)
98
+ s.handlers = null;
99
+ }
100
+ /** Write one framed user turn to the persistent child's stdin. */
101
+ writeTurn(id, text) {
102
+ const s = this._sessions.get(id);
103
+ if (!s || !s.child.stdin || s.child.stdin.destroyed)
104
+ return false;
105
+ return s.child.stdin.write(frameStreamJsonUserMessage(text));
106
+ }
107
+ /** Kill and forget one conversation's persistent child (idempotent). */
108
+ kill(id) {
109
+ const s = this._sessions.get(id);
110
+ if (!s)
111
+ return;
112
+ this._sessions.delete(id);
113
+ try {
114
+ s.reader?.close();
115
+ }
116
+ catch { /* best-effort */ }
117
+ try {
118
+ if (s.child.pid && !s.child.killed)
119
+ s.child.kill('SIGTERM');
120
+ }
121
+ catch { /* already gone */ }
122
+ }
123
+ /** Kill every persistent child (shutdown / project removal). */
124
+ killAll() {
125
+ for (const id of Array.from(this._sessions.keys()))
126
+ this.kill(id);
127
+ }
128
+ }
129
+ exports.ExploreStdinSessions = ExploreStdinSessions;