specrails-desktop 2.2.1 → 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.
- package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
- package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
- package/client/dist/assets/{AnalyticsPage-BD0paa75.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
- package/client/dist/assets/{BarChart-D8ZZRab3.js → BarChart-CMdLa6Es.js} +2 -2
- package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-mwd8460_.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
- package/client/dist/assets/{DocsDialog-D_dyF2h9.js → DocsDialog-D8yoyZDD.js} +2 -2
- package/client/dist/assets/{DocsPage-C9-Ru8wE.js → DocsPage-CeO-fAxy.js} +2 -2
- package/client/dist/assets/{ExportDropdown-CLYmQhic.js → ExportDropdown-DuoZcdYN.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-3WWtx9hi.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
- package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
- package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
- package/client/dist/assets/code-BtsmPQLV.js +1 -0
- package/client/dist/assets/code-CY85RXZU.js +1 -0
- package/client/dist/assets/code-Coa8f2Sh.js +1 -0
- package/client/dist/assets/code-D1z-YDt-.js +1 -0
- package/client/dist/assets/code-DDU0CRS0.js +1 -0
- package/client/dist/assets/code-L35Loak_.js +1 -0
- package/client/dist/assets/code-g0qFMzyg.js +1 -0
- package/client/dist/assets/code-zCwBt3Uu.js +1 -0
- package/client/dist/assets/{dist-js-BOu_cXw3.js → dist-js-4UEGaKhD.js} +1 -1
- package/client/dist/assets/{dist-js-D3MxtOYa.js → dist-js-H6hyhSuv.js} +1 -1
- package/client/dist/assets/{index-D9G_K4L-.js → index-CGHKpC-N.js} +11 -11
- package/client/dist/assets/{lib-DQ2hrj8m.js → lib-Cs5FrUJI.js} +1 -1
- package/client/dist/assets/{useProjectCache-BxY4aTjd.js → useProjectCache-BZWYV-w-.js} +1 -1
- package/client/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/dist/agent-refine-manager.js +128 -153
- package/server/dist/chat-manager.js +242 -0
- package/server/dist/code-explorer-router.js +78 -0
- package/server/dist/command-resolver.js +17 -0
- package/server/dist/contract-refine-runner.js +42 -10
- package/server/dist/db.js +6 -0
- package/server/dist/desktop-db.js +3 -0
- package/server/dist/explore-stdin-session.js +129 -0
- package/server/dist/mobile/mobile-auth.js +16 -0
- package/server/dist/project-router-chat.js +218 -0
- package/server/dist/project-router-helpers.js +275 -0
- package/server/dist/project-router-jobs.js +389 -0
- package/server/dist/project-router-settings.js +312 -0
- package/server/dist/project-router-setup.js +456 -0
- package/server/dist/project-router-spending.js +320 -0
- package/server/dist/project-router-terminals.js +312 -0
- package/server/dist/project-router-tickets.js +1767 -0
- package/server/dist/project-router.js +27 -3950
- package/server/dist/providers/claude-adapter.js +23 -0
- package/server/dist/providers/codex-adapter.js +6 -0
- package/server/dist/spawn-lifecycle.js +117 -0
- package/client/dist/assets/ActivityFeedPage-BpjXuX2H.js +0 -1
- package/client/dist/assets/AgentsPage-D-7fDbTc.js +0 -86
- package/client/dist/assets/CodePage-B6q6CiYJ.js +0 -2
- package/client/dist/assets/JobDetailPage-DgN-79s-.js +0 -16
- package/client/dist/assets/JobsPage-Du8_w1ob.js +0 -1
- package/client/dist/assets/code-AL1rVIMb.js +0 -1
- package/client/dist/assets/code-C0BKpkht.js +0 -1
- package/client/dist/assets/code-C0FTS3ew.js +0 -1
- package/client/dist/assets/code-CPcHxzxw.js +0 -1
- package/client/dist/assets/code-D3ryDniw.js +0 -1
- package/client/dist/assets/code-D3zVVQTj.js +0 -1
- package/client/dist/assets/code-PCmfS3dn.js +0 -1
- package/client/dist/assets/code-exI0G5Wd.js +0 -1
|
@@ -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)
|
|
@@ -547,6 +556,19 @@ class ChatManager {
|
|
|
547
556
|
// not pipeline jobs. Telemetry is scoped to QueueManager pipeline runs only.
|
|
548
557
|
// spawnAiCli reroutes multi-line argv values through stdin on Windows.
|
|
549
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
|
+
}
|
|
550
572
|
const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
|
|
551
573
|
env: process.env,
|
|
552
574
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -866,6 +888,223 @@ class ChatManager {
|
|
|
866
888
|
this._reservedTurns.delete(conversationId);
|
|
867
889
|
}
|
|
868
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
|
+
}
|
|
869
1108
|
abort(conversationId) {
|
|
870
1109
|
const child = this._activeProcesses.get(conversationId);
|
|
871
1110
|
if (!child || !child.pid)
|
|
@@ -893,6 +1132,7 @@ class ChatManager {
|
|
|
893
1132
|
clearTimeout(this._exploreQueue[idx].timeoutTimer);
|
|
894
1133
|
this._exploreQueue.splice(idx, 1);
|
|
895
1134
|
}
|
|
1135
|
+
this._stdinSessions.kill(conversationId);
|
|
896
1136
|
this._exploreLifecycle.delete(conversationId);
|
|
897
1137
|
}
|
|
898
1138
|
/**
|
|
@@ -911,6 +1151,8 @@ class ChatManager {
|
|
|
911
1151
|
catch { /* best-effort */ }
|
|
912
1152
|
}
|
|
913
1153
|
}
|
|
1154
|
+
// Persistent-stdin children outlive individual turns — tear them down too.
|
|
1155
|
+
this._stdinSessions.killAll();
|
|
914
1156
|
for (const id of this._exploreLifecycle.keys()) {
|
|
915
1157
|
this._clearIdleTimer(id);
|
|
916
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
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;
|
|
@@ -29,6 +29,10 @@ function createMobileAuthMiddleware(opts) {
|
|
|
29
29
|
const clock = opts.clock ?? (() => Date.now());
|
|
30
30
|
const lastTouch = new Map();
|
|
31
31
|
const ipHits = new Map();
|
|
32
|
+
// Periodically evict stale keys so these maps can't grow unbounded under a
|
|
33
|
+
// multi-source flood (each per-IP timestamp array self-bounds to the 60s
|
|
34
|
+
// window, but the Map KEYS would otherwise live forever, one per distinct IP).
|
|
35
|
+
let lastSweep = clock();
|
|
32
36
|
return function mobileAuth(req, res, next) {
|
|
33
37
|
// 1. Refuse browser-origin requests outright.
|
|
34
38
|
if (req.headers['origin']) {
|
|
@@ -38,6 +42,18 @@ function createMobileAuthMiddleware(opts) {
|
|
|
38
42
|
// 2. Coarse per-IP rate limit.
|
|
39
43
|
const ip = req.socket?.remoteAddress ?? 'unknown';
|
|
40
44
|
const now = clock();
|
|
45
|
+
// Sweep stale tracking at most once per window (cheap, amortized O(1)).
|
|
46
|
+
if (now - lastSweep > 60_000) {
|
|
47
|
+
lastSweep = now;
|
|
48
|
+
for (const [k, v] of ipHits) {
|
|
49
|
+
if (v.length === 0 || now - v[v.length - 1] >= 60_000)
|
|
50
|
+
ipHits.delete(k);
|
|
51
|
+
}
|
|
52
|
+
for (const [k, t] of lastTouch) {
|
|
53
|
+
if (now - t >= 3_600_000)
|
|
54
|
+
lastTouch.delete(k); // drop device touch-cache after 1h idle
|
|
55
|
+
}
|
|
56
|
+
}
|
|
41
57
|
const hits = (ipHits.get(ip) ?? []).filter((t) => now - t < 60_000);
|
|
42
58
|
hits.push(now);
|
|
43
59
|
ipHits.set(ip, hits);
|