specrails-desktop 2.4.0 → 2.6.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 (105) hide show
  1. package/client/dist/assets/{ActivityFeedPage-DJJlZ3mF.js → ActivityFeedPage-CoWwVcty.js} +1 -1
  2. package/client/dist/assets/AgentsPage-CgPvynWc.js +86 -0
  3. package/client/dist/assets/{AnalyticsPage-BUd3gWYC.js → AnalyticsPage-ioz3Ub2D.js} +1 -1
  4. package/client/dist/assets/{BarChart-HDe_YoUD.js → BarChart-BKXQPcoW.js} +1 -1
  5. package/client/dist/assets/CodePage-CYhXRKiI.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-CgvmSvF0.js → DesktopAnalyticsPage-CBfPCT3q.js} +1 -1
  7. package/client/dist/assets/DocsDialog-uRTBV-3T.js +11 -0
  8. package/client/dist/assets/DocsPage-gH0Lc54I.js +11 -0
  9. package/client/dist/assets/{ExportDropdown-f4dwQjlT.js → ExportDropdown-DAp7zWib.js} +1 -1
  10. package/client/dist/assets/IntegrationsPage-D40Si_7s.js +3 -0
  11. package/client/dist/assets/JobDetailPage-DSxAvB1n.js +16 -0
  12. package/client/dist/assets/JobsPage-ZMBc1BHE.js +1 -0
  13. package/client/dist/assets/dashboard--Ahnvfr3.js +1 -0
  14. package/client/dist/assets/dashboard-BN1C2pEh.js +1 -0
  15. package/client/dist/assets/dashboard-BZs_EzAn.js +1 -0
  16. package/client/dist/assets/dashboard-Bsw44L8_.js +1 -0
  17. package/client/dist/assets/dashboard-Bw3VECgY.js +1 -0
  18. package/client/dist/assets/{dashboard-Duo4DDCW.js → dashboard-CuOshSHn.js} +1 -1
  19. package/client/dist/assets/dashboard-DfouCM3_.js +1 -0
  20. package/client/dist/assets/dashboard-Pp5hwnZB.js +1 -0
  21. package/client/dist/assets/{dist-js-COfIfLRE.js → dist-js-CKqmDyXR.js} +1 -1
  22. package/client/dist/assets/{dist-js-CvScGQU_.js → dist-js-bTZuok_W.js} +1 -1
  23. package/client/dist/assets/{index-DGIXKRHE.js → index-B9IKK_QQ.js} +45 -45
  24. package/client/dist/assets/index-BqAXaTbC.css +2 -0
  25. package/client/dist/assets/jobs-BGkI19S_.js +1 -0
  26. package/client/dist/assets/jobs-Brp44JDd.js +1 -0
  27. package/client/dist/assets/jobs-D93lG6If.js +1 -0
  28. package/client/dist/assets/jobs-DAF8AGy5.js +1 -0
  29. package/client/dist/assets/jobs-Db3xrsp_.js +1 -0
  30. package/client/dist/assets/jobs-Do4Ltqdj.js +1 -0
  31. package/client/dist/assets/jobs-F5PGJwbW.js +1 -0
  32. package/client/dist/assets/jobs-fYWWxCUV.js +1 -0
  33. package/client/dist/assets/{lib-Bro9Z0gp.js → lib-B5mjOeEi.js} +1 -1
  34. package/client/dist/assets/{settings-D3LurcR5.js → settings-BI_cVCqN.js} +1 -1
  35. package/client/dist/assets/{settings-5tzo0Rn3.js → settings-BRaLLSVi.js} +1 -1
  36. package/client/dist/assets/{settings-BEWv3VEu.js → settings-BcqH0oea.js} +1 -1
  37. package/client/dist/assets/settings-C0-7Fpxg.js +1 -0
  38. package/client/dist/assets/{settings-BORg56um.js → settings-D6QMBlGQ.js} +1 -1
  39. package/client/dist/assets/{settings-DcqWIEM6.js → settings-GOBKOTGl.js} +1 -1
  40. package/client/dist/assets/{settings-BDAW3trC.js → settings-pT3MzfRu.js} +1 -1
  41. package/client/dist/assets/{settings-Dfz8QbZS.js → settings-u-16ISHt.js} +1 -1
  42. package/client/dist/assets/{setup-D3rNZA9A.js → setup-BIIkb-_K.js} +1 -1
  43. package/client/dist/assets/{setup-C1IA-9YS.js → setup-BeQxu9kD.js} +1 -1
  44. package/client/dist/assets/{setup-pjgmYHx6.js → setup-CPa6GnlI.js} +1 -1
  45. package/client/dist/assets/{setup-gzLG8T6F.js → setup-CZl4OEJx.js} +1 -1
  46. package/client/dist/assets/{setup-C0dzw8j4.js → setup-ChpodNfn.js} +1 -1
  47. package/client/dist/assets/{setup-WP6WOYQh.js → setup-D_fjJH6u.js} +1 -1
  48. package/client/dist/assets/{setup-UD2aanGs.js → setup-YzD8DX4O.js} +1 -1
  49. package/client/dist/assets/{setup-CpfjaNut.js → setup-fRpDozmq.js} +1 -1
  50. package/client/dist/assets/{specs-DicWhvwi.js → specs-B4GuOzuZ.js} +1 -1
  51. package/client/dist/assets/{specs-CXNQzPk9.js → specs-BVLKe2n5.js} +1 -1
  52. package/client/dist/assets/{specs-dkro6lSM.js → specs-C62F2CDv.js} +1 -1
  53. package/client/dist/assets/{specs-4lA_u79w.js → specs-D-Sb6dre.js} +1 -1
  54. package/client/dist/assets/{specs-DgmyAE3N.js → specs-DFSkAeK8.js} +1 -1
  55. package/client/dist/assets/{specs-DZCLH2-l.js → specs-DfwDeADE.js} +1 -1
  56. package/client/dist/assets/{specs-BHjxcjOf.js → specs-VK-zXv7x.js} +1 -1
  57. package/client/dist/assets/{specs-DFnc2Huj.js → specs-ghyBMnib.js} +1 -1
  58. package/client/dist/assets/{useProjectCache-D9juBhsO.js → useProjectCache-Cf83MBQh.js} +1 -1
  59. package/client/dist/index.html +7 -7
  60. package/docs/internals/api-reference.md +4 -7
  61. package/package.json +2 -2
  62. package/server/dist/chat-manager.js +19 -7
  63. package/server/dist/context-scope.js +29 -8
  64. package/server/dist/db.js +47 -2
  65. package/server/dist/feature-flags.js +15 -0
  66. package/server/dist/interactive-job-session.js +363 -0
  67. package/server/dist/mobile/index.js +5 -5
  68. package/server/dist/mobile/mobile-admin-router.js +28 -35
  69. package/server/dist/mobile/mobile-datachannel.js +167 -0
  70. package/server/dist/mobile/mobile-gateway.js +72 -98
  71. package/server/dist/mobile/mobile-router.js +4 -35
  72. package/server/dist/mobile/mobile-signal-reconnect.js +84 -0
  73. package/server/dist/mobile/mobile-types.js +5 -5
  74. package/server/dist/mobile/mobile-webrtc-peer.js +129 -0
  75. package/server/dist/mobile/mobile-webrtc.js +117 -0
  76. package/server/dist/project-router-jobs.js +42 -0
  77. package/server/dist/queue-manager.js +214 -54
  78. package/server/dist/rails-router.js +15 -1
  79. package/server/dist/util/stream-display.js +66 -0
  80. package/client/dist/assets/AgentsPage-49JaEDjR.js +0 -86
  81. package/client/dist/assets/CodePage-CqPPND47.js +0 -2
  82. package/client/dist/assets/DocsDialog-hHFd3Ejs.js +0 -11
  83. package/client/dist/assets/DocsPage-B4R1aksg.js +0 -11
  84. package/client/dist/assets/IntegrationsPage-CX2Ybxx0.js +0 -3
  85. package/client/dist/assets/JobDetailPage-DN2Jc8Ti.js +0 -16
  86. package/client/dist/assets/JobsPage-DmdpqijT.js +0 -1
  87. package/client/dist/assets/dashboard--Y6yzMlf.js +0 -1
  88. package/client/dist/assets/dashboard--a4-6oYE.js +0 -1
  89. package/client/dist/assets/dashboard-BiJ3CDTG.js +0 -1
  90. package/client/dist/assets/dashboard-CiXjk63Z.js +0 -1
  91. package/client/dist/assets/dashboard-Cx5VjCea.js +0 -1
  92. package/client/dist/assets/dashboard-D7jg25XR.js +0 -1
  93. package/client/dist/assets/dashboard-DpGYK2s1.js +0 -1
  94. package/client/dist/assets/index-DBpvYrDK.css +0 -2
  95. package/client/dist/assets/jobs-8viuHLDV.js +0 -1
  96. package/client/dist/assets/jobs-AW2eB5D-.js +0 -1
  97. package/client/dist/assets/jobs-BSm89DL5.js +0 -1
  98. package/client/dist/assets/jobs-BZ3sQHjZ.js +0 -1
  99. package/client/dist/assets/jobs-Bd8AdOTb.js +0 -1
  100. package/client/dist/assets/jobs-CRtsq_u0.js +0 -1
  101. package/client/dist/assets/jobs-CSRwFQ6K.js +0 -1
  102. package/client/dist/assets/jobs-CbEl7WMI.js +0 -1
  103. package/client/dist/assets/settings-yMubjqYw.js +0 -1
  104. package/server/dist/mobile/mobile-mdns.js +0 -81
  105. package/server/dist/mobile/mobile-pairing.js +0 -179
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MobileWebrtcGateway = void 0;
4
+ const werift_1 = require("werift");
5
+ const auth_1 = require("../auth");
6
+ const mobile_datachannel_1 = require("./mobile-datachannel");
7
+ class MobileWebrtcGateway {
8
+ _deps;
9
+ _pending = null;
10
+ _active = new Set();
11
+ constructor(_deps) {
12
+ this._deps = _deps;
13
+ }
14
+ /** Drop the open (un-answered) offer. */
15
+ clearOffer() {
16
+ const p = this._pending;
17
+ this._pending = null;
18
+ if (p)
19
+ this._close(p.pc);
20
+ }
21
+ /** Create a fresh pairing offer (peer + DataChannel + gathered offer SDP).
22
+ * Replaces any previously-open offer. Returns the SDP to embed in the QR. */
23
+ async createOffer(secret) {
24
+ this.clearOffer();
25
+ const pc = new werift_1.RTCPeerConnection({ iceServers: [] });
26
+ const dc = pc.createDataChannel('mobile');
27
+ // The companion peer activates when the channel opens. It validates `hello`
28
+ // against THIS connection's offer secret (captured in this closure) — not a
29
+ // global pending secret, which is already null by the time hello arrives.
30
+ new mobile_datachannel_1.DataChannelPeer(toChannelLike(dc), {
31
+ registerDevice: async (input) => input.secret && (0, auth_1.safeEqual)(input.secret, secret)
32
+ ? this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
33
+ : { ok: false },
34
+ rpcDispatch: this._deps.rpcDispatch,
35
+ bridge: this._deps.bridge,
36
+ });
37
+ pc.connectionStateChange.subscribe((s) => {
38
+ console.log('[webrtc] pc connectionState:', s);
39
+ if (s === 'failed' || s === 'closed' || s === 'disconnected') {
40
+ this._active.delete(pc);
41
+ if (this._pending?.pc === pc)
42
+ this._pending = null;
43
+ }
44
+ });
45
+ const offer = await pc.createOffer();
46
+ await pc.setLocalDescription(offer);
47
+ await waitIceComplete(pc);
48
+ const sdp = pc.localDescription?.sdp;
49
+ if (!sdp) {
50
+ this._close(pc);
51
+ throw new Error('no local description after ICE gathering');
52
+ }
53
+ console.log(`[webrtc] offer ready: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
54
+ this._pending = { pc, secret };
55
+ return { sdp };
56
+ }
57
+ /** Apply the companion's scanned answer SDP to the open offer and keep the
58
+ * connection alive for the session. */
59
+ async acceptAnswer(sdp) {
60
+ const p = this._pending;
61
+ if (!p)
62
+ return { ok: false };
63
+ try {
64
+ console.log(`[webrtc] applying answer: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
65
+ await p.pc.setRemoteDescription({ type: 'answer', sdp });
66
+ this._active.add(p.pc);
67
+ this._pending = null;
68
+ return { ok: true };
69
+ }
70
+ catch {
71
+ return { ok: false };
72
+ }
73
+ }
74
+ /** Tear down every peer (gateway stopping / disabled). */
75
+ stop() {
76
+ this.clearOffer();
77
+ for (const pc of this._active)
78
+ this._close(pc);
79
+ this._active.clear();
80
+ }
81
+ _close(pc) {
82
+ try {
83
+ void pc.close();
84
+ }
85
+ catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+ exports.MobileWebrtcGateway = MobileWebrtcGateway;
91
+ function toChannelLike(dc) {
92
+ dc.stateChanged.subscribe((s) => console.log('[webrtc] datachannel state:', s));
93
+ return {
94
+ get readyState() {
95
+ return dc.readyState;
96
+ },
97
+ send: (data) => dc.send(data),
98
+ onMessage: (cb) => {
99
+ dc.onMessage.subscribe((d) => cb(typeof d === 'string' ? d : d.toString('utf8')));
100
+ },
101
+ onClose: (cb) => {
102
+ dc.stateChanged.subscribe((s) => {
103
+ if (s === 'closed')
104
+ cb();
105
+ });
106
+ },
107
+ };
108
+ }
109
+ /** Vanilla ICE: resolve once gathering completes so the offer SDP carries all
110
+ * candidates (there's no trickle channel — the SDP travels by QR). */
111
+ function waitIceComplete(pc) {
112
+ if (pc.iceGatheringState === 'complete')
113
+ return Promise.resolve();
114
+ return new Promise((resolve) => {
115
+ let done = false;
116
+ const finish = () => {
117
+ if (!done) {
118
+ done = true;
119
+ resolve();
120
+ }
121
+ };
122
+ pc.iceGatheringStateChange.subscribe((s) => {
123
+ if (s === 'complete')
124
+ finish();
125
+ });
126
+ const t = setTimeout(finish, 3000);
127
+ t.unref?.();
128
+ });
129
+ }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildRegisterDevice = buildRegisterDevice;
7
+ exports.buildRpcDispatch = buildRpcDispatch;
8
+ exports.createLoopbackFetch = createLoopbackFetch;
9
+ const https_1 = __importDefault(require("https"));
10
+ const crypto_1 = require("crypto");
11
+ const mobile_devices_1 = require("./mobile-devices");
12
+ // Glue for the serverless WebRTC companion, kept dependency-injected so the
13
+ // security-relevant bits (secret check, device creation, /v1 dispatch) are
14
+ // unit-tested without a real WebRTC peer, the DB driver, or HTTP. The werift
15
+ // runtime peer lives in ./mobile-webrtc-peer.ts and consumes these.
16
+ function toPlatform(p) {
17
+ return p === 'android' ? 'android' : p === 'ios' ? 'ios' : 'web';
18
+ }
19
+ /** Persists a paired device + issues its token. The single-use pairing-secret
20
+ * check is done per-connection by [MobileWebrtcGateway] (which holds each
21
+ * offer's secret for the life of that connection — the secret must NOT live on
22
+ * the global "pending offer", which is cleared the moment the answer is
23
+ * accepted, well before the companion's `hello` arrives). Approval is implicit:
24
+ * the desktop user actively scanned the phone's answer QR. */
25
+ function buildRegisterDevice(deps) {
26
+ const genToken = deps.genToken ?? (() => (0, crypto_1.randomBytes)(32).toString('hex'));
27
+ return async (input) => {
28
+ // Reconnect: a known, non-revoked device token → reuse that device (no new
29
+ // row, no growing the paired-devices list on every reconnect).
30
+ if (input.token) {
31
+ const existing = (0, mobile_devices_1.getActiveDeviceByTokenHash)(deps.db, (0, mobile_devices_1.hashToken)(input.token));
32
+ if (existing) {
33
+ return {
34
+ ok: true,
35
+ deviceId: existing.id,
36
+ token: input.token,
37
+ hubName: deps.desktopName(),
38
+ hubInstanceId: deps.desktopInstanceId(),
39
+ };
40
+ }
41
+ }
42
+ const token = genToken();
43
+ const row = (0, mobile_devices_1.createDevice)(deps.db, {
44
+ name: (input.deviceName || 'Web companion').slice(0, 80),
45
+ platform: toPlatform(input.platform),
46
+ tokenHash: (0, mobile_devices_1.hashToken)(token),
47
+ certFingerprint: deps.currentFingerprint(),
48
+ });
49
+ return {
50
+ ok: true,
51
+ deviceId: row.id,
52
+ token,
53
+ hubName: deps.desktopName(),
54
+ hubInstanceId: deps.desktopInstanceId(),
55
+ };
56
+ };
57
+ }
58
+ /** Builds the `rpcDispatch` used by [DataChannelPeer] on `rpc`. A thin proxy to
59
+ * the EXISTING `/v1` allow-list (which already validates params, forwards to the
60
+ * internal API, and redacts), authenticated with the device's bearer token. */
61
+ function buildRpcDispatch(deps) {
62
+ return async (input) => {
63
+ // Only the /v1 surface is reachable; the gateway enforces the allow-list.
64
+ if (input.path !== '/v1' && !input.path.startsWith('/v1/')) {
65
+ return { status: 404, json: { error: 'not found' } };
66
+ }
67
+ const method = input.method.toUpperCase();
68
+ const headers = { Authorization: `Bearer ${input.token}` };
69
+ let body;
70
+ if (input.body !== undefined && method !== 'GET' && method !== 'DELETE') {
71
+ headers['Content-Type'] = 'application/json';
72
+ body = JSON.stringify(input.body);
73
+ }
74
+ try {
75
+ const res = await deps.doFetch(deps.gatewayBase + input.path, { method, headers, body });
76
+ const text = await res.text();
77
+ let json = {};
78
+ try {
79
+ json = text ? JSON.parse(text) : {};
80
+ }
81
+ catch {
82
+ json = {};
83
+ }
84
+ return { status: res.status, json };
85
+ }
86
+ catch {
87
+ return { status: 502, json: { error: 'gateway unreachable' } };
88
+ }
89
+ };
90
+ }
91
+ /** A loopback `fetch` for the local /v1 gateway. It deliberately ignores the
92
+ * gateway's self-signed cert — the connection never leaves 127.0.0.1, and the
93
+ * device bearer token (not the TLS cert) is the auth boundary. */
94
+ function createLoopbackFetch() {
95
+ return (url, init) => new Promise((resolve, reject) => {
96
+ const u = new URL(url);
97
+ const req = https_1.default.request({
98
+ hostname: u.hostname,
99
+ port: u.port,
100
+ path: u.pathname + u.search,
101
+ method: init.method,
102
+ headers: init.headers,
103
+ rejectUnauthorized: false,
104
+ }, (res) => {
105
+ let data = '';
106
+ res.setEncoding('utf8');
107
+ res.on('data', (c) => {
108
+ data += c;
109
+ });
110
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, text: async () => data }));
111
+ });
112
+ req.on('error', reject);
113
+ if (init.body)
114
+ req.write(init.body);
115
+ req.end();
116
+ });
117
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerJobsRoutes = registerJobsRoutes;
4
4
  const db_1 = require("./db");
5
5
  const queue_manager_1 = require("./queue-manager");
6
+ const feature_flags_1 = require("./feature-flags");
6
7
  const types_1 = require("./types");
7
8
  const hooks_1 = require("./hooks");
8
9
  const explore_smash_1 = require("./explore-smash");
@@ -118,6 +119,47 @@ function registerJobsRoutes(deps) {
118
119
  }
119
120
  }
120
121
  });
122
+ // ─── Interactive ultracode jobs ────────────────────────────────────────────
123
+ // Send one more user prompt to a running interactive job (queued behind the
124
+ // active turn — see InteractiveJobSession). 202 = accepted; 409 = the job is
125
+ // not an active interactive session (unknown / non-interactive / finalized).
126
+ router.post('/:projectId/jobs/:id/messages', (req, res) => {
127
+ if (!(0, feature_flags_1.isInteractiveJobsEnabled)()) {
128
+ res.status(403).json({ error: 'Interactive jobs are disabled on this server' });
129
+ return;
130
+ }
131
+ const { text } = req.body ?? {};
132
+ if (!text || typeof text !== 'string' || !text.trim()) {
133
+ res.status(400).json({ error: 'text is required' });
134
+ return;
135
+ }
136
+ const { queueManager } = ctx(req);
137
+ const jobId = req.params.id;
138
+ const accepted = queueManager.sendInteractiveTurn(jobId, text);
139
+ if (!accepted) {
140
+ res.status(409).json({ error: 'Job is not an active interactive session' });
141
+ return;
142
+ }
143
+ res.status(202).json({ ok: true });
144
+ });
145
+ // Finalize a running interactive job: SIGTERM the resident child; the summed
146
+ // token/cost totals + 'completed' status are stamped asynchronously when the
147
+ // child closes (the client also learns the final state via the job.finalized
148
+ // WS broadcast). 202 = finalize scheduled; 409 = not an active interactive job.
149
+ router.post('/:projectId/jobs/:id/finalize', (req, res) => {
150
+ if (!(0, feature_flags_1.isInteractiveJobsEnabled)()) {
151
+ res.status(403).json({ error: 'Interactive jobs are disabled on this server' });
152
+ return;
153
+ }
154
+ const { db, queueManager } = ctx(req);
155
+ const jobId = req.params.id;
156
+ const scheduled = queueManager.finalizeInteractive(jobId);
157
+ if (!scheduled) {
158
+ res.status(409).json({ error: 'Job is not an active interactive session' });
159
+ return;
160
+ }
161
+ res.status(202).json({ ok: true, job: (0, db_1.getJob)(db, jobId) ?? null });
162
+ });
121
163
  router.patch('/:projectId/jobs/:id/priority', (req, res) => {
122
164
  const { priority } = req.body ?? {};
123
165
  if (!priority || !types_1.VALID_PRIORITIES.has(priority)) {
@@ -14,6 +14,7 @@ const tree_kill_1 = __importDefault(require("tree-kill"));
14
14
  const types_1 = require("./types");
15
15
  const command_resolver_1 = require("./command-resolver");
16
16
  const cli_prompt_1 = require("./util/cli-prompt");
17
+ const stream_display_1 = require("./util/stream-display");
17
18
  const hooks_1 = require("./hooks");
18
19
  const ai_invocations_1 = require("./ai-invocations");
19
20
  const feature_flags_1 = require("./feature-flags");
@@ -23,6 +24,7 @@ const crypto_1 = require("crypto");
23
24
  const providers_1 = require("./providers");
24
25
  const codex_otel_bridge_1 = require("./codex-otel-bridge");
25
26
  const db_1 = require("./db");
27
+ const interactive_job_session_1 = require("./interactive-job-session");
26
28
  const attachment_manager_1 = require("./attachment-manager");
27
29
  const ticket_store_1 = require("./ticket-store");
28
30
  const binary_probe_1 = require("./binary-probe");
@@ -104,59 +106,6 @@ class JobAlreadyTerminalError extends Error {
104
106
  }
105
107
  exports.JobAlreadyTerminalError = JobAlreadyTerminalError;
106
108
  // ─── Helpers ──────────────────────────────────────────────────────────────────
107
- function extractDisplayText(event) {
108
- const type = event.type;
109
- // ── Claude `--output-format stream-json` ───────────────────────────────
110
- if (type === 'assistant') {
111
- const content = event.message;
112
- const texts = (content?.content ?? [])
113
- .filter((c) => c.type === 'text')
114
- .map((c) => c.text ?? '');
115
- return texts.join('') || null;
116
- }
117
- if (type === 'tool_use') {
118
- const name = event.name;
119
- const input = JSON.stringify(event.input ?? {});
120
- return `[tool: ${name}] ${input.slice(0, 120)}`;
121
- }
122
- if (type === 'tool_result' || type === 'system_prompt' || type === 'user' || type === 'system' || type === 'result') {
123
- return null;
124
- }
125
- // ── Codex `exec --json` event types ───────────────────────────────────
126
- // Codex shape differs from claude: items are nested under `item` with a
127
- // discriminator at `item.type`. Without explicit handling the Job Detail
128
- // log shows only the spawn preamble and exit notice — exactly the
129
- // "2 / 2 lines" symptom that masks 200k+ tokens of real work.
130
- if (type === 'item.completed' || type === 'item.started') {
131
- const item = event.item;
132
- if (!item)
133
- return null;
134
- const itemType = item.type;
135
- if (itemType === 'agent_message') {
136
- const text = item.text?.trim();
137
- return text && text.length > 0 ? text : null;
138
- }
139
- if (itemType === 'command_execution') {
140
- // Only surface the completed line so the log isn't doubled with the
141
- // matching `item.started` placeholder.
142
- if (type !== 'item.completed')
143
- return null;
144
- const cmd = item.command ?? '';
145
- const exitCode = item.exit_code;
146
- const exitStr = typeof exitCode === 'number' ? ` → exit ${exitCode}` : '';
147
- return `[exec]${exitStr} ${cmd.slice(0, 200)}`;
148
- }
149
- if (itemType === 'agent_reasoning') {
150
- const text = item.text?.trim();
151
- return text && text.length > 0 ? `[reasoning] ${text.slice(0, 200)}` : null;
152
- }
153
- return null;
154
- }
155
- if (type === 'thread.started' || type === 'turn.started' || type === 'turn.completed') {
156
- return null;
157
- }
158
- return null;
159
- }
160
109
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'zombie_terminated', 'skipped']);
161
110
  /** Match an Ultracode rail command: `/specrails:ultracode #5 …` (or `/sr:…`). */
162
111
  exports.ULTRACODE_COMMAND_RE = /^\/(specrails|sr):ultracode\b/;
@@ -207,6 +156,12 @@ class QueueManager {
207
156
  /** Pre-spawn working-tree snapshot refs keyed by jobId — read at exit time
208
157
  * by the Code-Explorer provenance hook. Cleared on job exit. */
209
158
  _snapshotRefs;
159
+ /** Pending per-job interactive flag keyed by jobId — read at spawn time.
160
+ * In-memory only (mirrors _jobModelSelection). */
161
+ _jobInteractiveSelection;
162
+ /** Live interactive job sessions keyed by jobId (the resident persistent-stdin
163
+ * child + per-turn accounting). Present only while an interactive job runs. */
164
+ _interactiveSessions;
210
165
  constructor(broadcast, db, commands, cwd, options) {
211
166
  this._queue = [];
212
167
  this._jobs = new Map();
@@ -235,6 +190,8 @@ class QueueManager {
235
190
  this._jobProviderSelection = new Map();
236
191
  this._jobModelSelection = new Map();
237
192
  this._snapshotRefs = new Map();
193
+ this._jobInteractiveSelection = new Map();
194
+ this._interactiveSessions = new Map();
238
195
  const envTimeout = process.env.WM_ZOMBIE_TIMEOUT_MS !== undefined
239
196
  ? parseInt(process.env.WM_ZOMBIE_TIMEOUT_MS, 10)
240
197
  : null;
@@ -293,6 +250,15 @@ class QueueManager {
293
250
  }
294
251
  this._activeProcess = null;
295
252
  this._activeJobId = null;
253
+ // Tear down any resident interactive sessions (SIGTERM their children) so
254
+ // teardown orphans no persistent claude process. dispose() does not settle.
255
+ for (const session of this._interactiveSessions.values()) {
256
+ try {
257
+ session.dispose();
258
+ }
259
+ catch { /* best-effort */ }
260
+ }
261
+ this._interactiveSessions.clear();
296
262
  // Release any per-job provenance snapshots so teardown leaves no map entries.
297
263
  this._snapshotRefs.clear();
298
264
  // Drop the DB reference last so any in-flight 'close' callback sees null
@@ -357,6 +323,11 @@ class QueueManager {
357
323
  if (resolvedOpts?.model) {
358
324
  this._jobModelSelection.set(id, resolvedOpts.model);
359
325
  }
326
+ // Record per-job interactive flag (ultracode + claude only — enforced at
327
+ // spawn time against the resolved adapter's persistent-stdin capability).
328
+ if (resolvedOpts?.interactive) {
329
+ this._jobInteractiveSelection.set(id, true);
330
+ }
360
331
  // Insert at the correct position based on priority (higher priority first, FIFO within same level)
361
332
  const weight = types_1.PRIORITY_WEIGHT[priority];
362
333
  let insertIdx = this._queue.length;
@@ -395,6 +366,7 @@ class QueueManager {
395
366
  this._jobProviderSelection.delete(jobId);
396
367
  this._jobModelSelection.delete(jobId);
397
368
  this._jobProfileSelection.delete(jobId);
369
+ this._jobInteractiveSelection.delete(jobId);
398
370
  this._skipDependents(jobId, `Parent job ${jobId} was canceled`);
399
371
  this._recomputePositions();
400
372
  this._persistJob(job);
@@ -415,6 +387,15 @@ class QueueManager {
415
387
  return 'canceled';
416
388
  }
417
389
  // job.status === 'running'
390
+ // Interactive jobs own a resident child via the session (not _activeProcess),
391
+ // so route their cancel through the session: SIGTERM → settle sees the
392
+ // canceling flag and stamps 'canceled'.
393
+ const interactiveSession = this._interactiveSessions.get(jobId);
394
+ if (interactiveSession) {
395
+ this._cancelingJobs.add(jobId);
396
+ interactiveSession.finalize();
397
+ return 'canceling';
398
+ }
418
399
  this._kill(jobId);
419
400
  return 'canceling';
420
401
  }
@@ -490,6 +471,29 @@ class QueueManager {
490
471
  getLogBuffer() {
491
472
  return [...this._logBuffer];
492
473
  }
474
+ /** True while an interactive ultracode session is resident for this job. */
475
+ isInteractiveJob(jobId) {
476
+ return this._interactiveSessions.has(jobId);
477
+ }
478
+ /** Feed one more user prompt to a running interactive job (queued behind the
479
+ * active turn). Returns false when the job isn't an active interactive
480
+ * session (unknown / already finalized / not interactive). */
481
+ sendInteractiveTurn(jobId, text) {
482
+ const session = this._interactiveSessions.get(jobId);
483
+ if (!session)
484
+ return false;
485
+ return session.send(text);
486
+ }
487
+ /** User-initiated finalize for an interactive job: SIGTERM the resident child;
488
+ * the settle path stamps the summed totals + 'completed' status. Returns false
489
+ * when the job isn't an active interactive session. */
490
+ finalizeInteractive(jobId) {
491
+ const session = this._interactiveSessions.get(jobId);
492
+ if (!session)
493
+ return false;
494
+ session.finalize();
495
+ return true;
496
+ }
493
497
  // ─── Private methods ────────────────────────────────────────────────────────
494
498
  phasesForCommand(command) {
495
499
  return this._phasesForCommand(command);
@@ -630,6 +634,143 @@ class QueueManager {
630
634
  }
631
635
  return this._adapter;
632
636
  }
637
+ /**
638
+ * Spawn an interactive ultracode session. The job row is created with the
639
+ * `interactive` flag set; the resident child runs the first turn (the
640
+ * ultracode prompt) and stays alive for follow-up turns until finalize/crash.
641
+ * No zombie timer is armed (the child idles between turns by design) and
642
+ * `_activeProcess` is left null — the session owns the child; the active SLOT
643
+ * (`_activeJobId`, reserved by _drainQueue) is held until settle.
644
+ */
645
+ _startInteractiveJob(jobId, job, adapter, spec, firstPrompt) {
646
+ if (this._db) {
647
+ try {
648
+ (0, db_1.createJob)(this._db, {
649
+ id: jobId,
650
+ command: job.command,
651
+ started_at: job.startedAt,
652
+ priority: job.priority,
653
+ depends_on_job_id: job.dependsOnJobId,
654
+ pipeline_id: job.pipelineId,
655
+ interactive: true,
656
+ });
657
+ }
658
+ catch (err) {
659
+ console.error('[queue-manager] createJob (interactive) failed:', err);
660
+ }
661
+ }
662
+ const session = new interactive_job_session_1.InteractiveJobSession({
663
+ jobId,
664
+ projectId: this._projectId ?? '',
665
+ db: this._db,
666
+ adapter,
667
+ broadcast: this._broadcast,
668
+ onSettle: (info) => this._settleInteractiveJob(jobId, info),
669
+ });
670
+ this._interactiveSessions.set(jobId, session);
671
+ session.start(spec, firstPrompt);
672
+ this._broadcastQueueState();
673
+ }
674
+ /**
675
+ * Terminal bookkeeping for an interactive job (called once by the session's
676
+ * onSettle). Releases the active slot, stamps the job's terminal status +
677
+ * finished_at (token/cost totals were already accumulated per turn), writes a
678
+ * single ai_invocations row with the summed usage, fires the rail/ticket
679
+ * completion callback, and drains the queue.
680
+ */
681
+ _settleInteractiveJob(jobId, info) {
682
+ this._interactiveSessions.delete(jobId);
683
+ if (this._activeJobId === jobId) {
684
+ this._activeProcess = null;
685
+ this._activeJobId = null;
686
+ }
687
+ // Interactive jobs skip provenance, but a defensive delete keeps the map
688
+ // clean if a snapshot was ever recorded for this id.
689
+ this._snapshotRefs.delete(jobId);
690
+ if (this._disposed)
691
+ return;
692
+ const job = this._jobs.get(jobId);
693
+ if (!job) {
694
+ this._drainQueue();
695
+ return;
696
+ }
697
+ const wasCanceling = this._cancelingJobs.has(jobId);
698
+ this._cancelingJobs.delete(jobId);
699
+ const finalStatus = wasCanceling
700
+ ? 'canceled'
701
+ : info.reason === 'finalized'
702
+ ? 'completed'
703
+ : 'failed';
704
+ job.status = finalStatus;
705
+ job.finishedAt = new Date().toISOString();
706
+ job.exitCode = info.reason === 'finalized' ? 0 : 1;
707
+ const totals = info.totals;
708
+ if (this._db) {
709
+ try {
710
+ (0, db_1.finalizeInteractiveJob)(this._db, jobId, finalStatus);
711
+ }
712
+ catch (err) {
713
+ console.error('[queue-manager] finalizeInteractiveJob failed:', err);
714
+ }
715
+ if (this._projectId) {
716
+ try {
717
+ const invStatus = finalStatus === 'completed'
718
+ ? 'success'
719
+ : finalStatus === 'canceled'
720
+ ? 'aborted'
721
+ : 'failed';
722
+ const ticketIds = this._extractTicketIds(job.command);
723
+ const durationMs = job.startedAt
724
+ ? new Date(job.finishedAt).getTime() - new Date(job.startedAt).getTime()
725
+ : undefined;
726
+ (0, ai_invocations_1.recordInvocation)(this._db, {
727
+ id: (0, crypto_1.randomUUID)(),
728
+ project_id: this._projectId,
729
+ provider: 'claude',
730
+ surface: 'job',
731
+ surface_ref_id: jobId,
732
+ ticket_id: ticketIds[0] ?? null,
733
+ status: invStatus,
734
+ started_at: job.startedAt ?? new Date().toISOString(),
735
+ finished_at: job.finishedAt,
736
+ total_cost_usd_estimated: false,
737
+ tokens_in: totals.tokens_in,
738
+ tokens_out: totals.tokens_out,
739
+ tokens_cache_read: totals.tokens_cache_read,
740
+ tokens_cache_create: totals.tokens_cache_create,
741
+ total_cost_usd: totals.total_cost_usd,
742
+ num_turns: totals.num_turns,
743
+ model: info.model ?? undefined,
744
+ session_id: info.sessionId ?? undefined,
745
+ duration_ms: durationMs,
746
+ });
747
+ this._broadcast({ type: 'spending.invalidated', projectId: this._projectId });
748
+ }
749
+ catch (err) {
750
+ console.error('[queue-manager] recordInvocation (interactive) failed:', err);
751
+ }
752
+ }
753
+ }
754
+ this._persistJob(job);
755
+ this._broadcast({
756
+ type: 'job.finalized',
757
+ projectId: this._projectId ?? '',
758
+ jobId,
759
+ status: finalStatus,
760
+ totals,
761
+ timestamp: new Date().toISOString(),
762
+ });
763
+ this._broadcastQueueState();
764
+ if (this._onJobFinished) {
765
+ try {
766
+ this._onJobFinished(jobId, finalStatus, totals.total_cost_usd);
767
+ }
768
+ catch (err) {
769
+ console.error(`[QueueManager] onJobFinished failed for ${jobId}: ${err.message}`);
770
+ }
771
+ }
772
+ this._drainQueue();
773
+ }
633
774
  async _startJob(jobId) {
634
775
  const job = this._jobs.get(jobId);
635
776
  if (!job) {
@@ -859,6 +1000,25 @@ class QueueManager {
859
1000
  };
860
1001
  }
861
1002
  }
1003
+ // ─── Interactive ultracode branch ──────────────────────────────────────
1004
+ // When the launch requested interactive mode AND the command is ultracode
1005
+ // AND the adapter supports persistent stdin (claude), hand off to a resident
1006
+ // session instead of the one-shot spawn below. The session keeps the child
1007
+ // alive across turns and settles only on finalize/crash. Code-Explorer
1008
+ // provenance is intentionally skipped for interactive jobs (v1 — deferred).
1009
+ const wantsInteractive = this._jobInteractiveSelection.get(jobId) === true;
1010
+ this._jobInteractiveSelection.delete(jobId);
1011
+ if (wantsInteractive && isUltracode && adapter.capabilities.persistentStdin) {
1012
+ const interactiveArgs = adapter.buildArgs('chat-stream', {
1013
+ // chat-stream feeds the prompt over stdin per-turn, so the argv `prompt`
1014
+ // is unused — pass empty to satisfy the shared SpawnOptions shape.
1015
+ prompt: '',
1016
+ systemPrompt: systemAppend || undefined,
1017
+ model: railModel,
1018
+ });
1019
+ this._startInteractiveJob(jobId, job, adapter, { binary, args: interactiveArgs, cwd: this._cwd, env: spawnEnv }, railPrompt);
1020
+ return;
1021
+ }
862
1022
  // Code-Explorer pre-spawn snapshot. Captures the working-tree state via
863
1023
  // `git stash create --include-untracked` so the post-exit hook can diff
864
1024
  // against it. Gated by SPECRAILS_CODE_EXPLORER — when off, no-op.
@@ -996,7 +1156,7 @@ class QueueManager {
996
1156
  if (eventType === 'result') {
997
1157
  lastResultEvent = parsed;
998
1158
  }
999
- const displayText = extractDisplayText(parsed);
1159
+ const displayText = (0, stream_display_1.extractDisplayText)(parsed);
1000
1160
  if (displayText !== null) {
1001
1161
  if (this._db) {
1002
1162
  (0, db_1.appendEvent)(this._db, jobId, eventSeq++, {