specrails-desktop 2.4.0 → 2.5.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 (76) hide show
  1. package/client/dist/assets/{ActivityFeedPage-DJJlZ3mF.js → ActivityFeedPage-BTYWMRwB.js} +1 -1
  2. package/client/dist/assets/AgentsPage-BfOCeHHt.js +86 -0
  3. package/client/dist/assets/{AnalyticsPage-BUd3gWYC.js → AnalyticsPage-AbVXKh9v.js} +1 -1
  4. package/client/dist/assets/{BarChart-HDe_YoUD.js → BarChart-DlshJN3Z.js} +1 -1
  5. package/client/dist/assets/CodePage-DJCjDG4I.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-CgvmSvF0.js → DesktopAnalyticsPage-CTqZ9mbB.js} +1 -1
  7. package/client/dist/assets/DocsDialog-KiJOSRvX.js +11 -0
  8. package/client/dist/assets/DocsPage-B17CR54A.js +11 -0
  9. package/client/dist/assets/{ExportDropdown-f4dwQjlT.js → ExportDropdown-BAu6z3b6.js} +1 -1
  10. package/client/dist/assets/IntegrationsPage-CCG64Q-6.js +3 -0
  11. package/client/dist/assets/JobDetailPage-BnGJSMiS.js +16 -0
  12. package/client/dist/assets/JobsPage-B-tn4CIf.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-B16c3VyT.js} +1 -1
  22. package/client/dist/assets/{dist-js-CvScGQU_.js → dist-js-P2FkJ6fA.js} +1 -1
  23. package/client/dist/assets/{index-DGIXKRHE.js → index-AfVF6BgE.js} +34 -34
  24. package/client/dist/assets/index-NlH5BbXJ.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-rNNmltMb.js} +1 -1
  34. package/client/dist/assets/{specs-DicWhvwi.js → specs-B4GuOzuZ.js} +1 -1
  35. package/client/dist/assets/{specs-CXNQzPk9.js → specs-BVLKe2n5.js} +1 -1
  36. package/client/dist/assets/{specs-dkro6lSM.js → specs-C62F2CDv.js} +1 -1
  37. package/client/dist/assets/{specs-4lA_u79w.js → specs-D-Sb6dre.js} +1 -1
  38. package/client/dist/assets/{specs-DgmyAE3N.js → specs-DFSkAeK8.js} +1 -1
  39. package/client/dist/assets/{specs-DZCLH2-l.js → specs-DfwDeADE.js} +1 -1
  40. package/client/dist/assets/{specs-BHjxcjOf.js → specs-VK-zXv7x.js} +1 -1
  41. package/client/dist/assets/{specs-DFnc2Huj.js → specs-ghyBMnib.js} +1 -1
  42. package/client/dist/assets/{useProjectCache-D9juBhsO.js → useProjectCache-Cid_GxRM.js} +1 -1
  43. package/client/dist/index.html +5 -5
  44. package/package.json +1 -1
  45. package/server/dist/chat-manager.js +19 -7
  46. package/server/dist/context-scope.js +29 -8
  47. package/server/dist/db.js +47 -2
  48. package/server/dist/feature-flags.js +15 -0
  49. package/server/dist/interactive-job-session.js +363 -0
  50. package/server/dist/project-router-jobs.js +42 -0
  51. package/server/dist/queue-manager.js +214 -54
  52. package/server/dist/rails-router.js +15 -1
  53. package/server/dist/util/stream-display.js +66 -0
  54. package/client/dist/assets/AgentsPage-49JaEDjR.js +0 -86
  55. package/client/dist/assets/CodePage-CqPPND47.js +0 -2
  56. package/client/dist/assets/DocsDialog-hHFd3Ejs.js +0 -11
  57. package/client/dist/assets/DocsPage-B4R1aksg.js +0 -11
  58. package/client/dist/assets/IntegrationsPage-CX2Ybxx0.js +0 -3
  59. package/client/dist/assets/JobDetailPage-DN2Jc8Ti.js +0 -16
  60. package/client/dist/assets/JobsPage-DmdpqijT.js +0 -1
  61. package/client/dist/assets/dashboard--Y6yzMlf.js +0 -1
  62. package/client/dist/assets/dashboard--a4-6oYE.js +0 -1
  63. package/client/dist/assets/dashboard-BiJ3CDTG.js +0 -1
  64. package/client/dist/assets/dashboard-CiXjk63Z.js +0 -1
  65. package/client/dist/assets/dashboard-Cx5VjCea.js +0 -1
  66. package/client/dist/assets/dashboard-D7jg25XR.js +0 -1
  67. package/client/dist/assets/dashboard-DpGYK2s1.js +0 -1
  68. package/client/dist/assets/index-DBpvYrDK.css +0 -2
  69. package/client/dist/assets/jobs-8viuHLDV.js +0 -1
  70. package/client/dist/assets/jobs-AW2eB5D-.js +0 -1
  71. package/client/dist/assets/jobs-BSm89DL5.js +0 -1
  72. package/client/dist/assets/jobs-BZ3sQHjZ.js +0 -1
  73. package/client/dist/assets/jobs-Bd8AdOTb.js +0 -1
  74. package/client/dist/assets/jobs-CRtsq_u0.js +0 -1
  75. package/client/dist/assets/jobs-CSRwFQ6K.js +0 -1
  76. package/client/dist/assets/jobs-CbEl7WMI.js +0 -1
@@ -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++, {
@@ -5,6 +5,7 @@ const express_1 = require("express");
5
5
  const rails_store_1 = require("./rails-store");
6
6
  const queue_manager_1 = require("./queue-manager");
7
7
  const provider_selection_1 = require("./provider-selection");
8
+ const feature_flags_1 = require("./feature-flags");
8
9
  const VALID_MODES = new Set(['implement', 'batch-implement', 'ultracode']);
9
10
  // Models the ultracode picker exposes (Claude aliases). Mirrors the client
10
11
  // RailModelSelector options and the project-router orchestrator-model allow-list.
@@ -189,7 +190,7 @@ function createRailsRouter() {
189
190
  res.status(400).json({ error: 'Invalid rail index' });
190
191
  return;
191
192
  }
192
- const { mode = 'implement', profileName, aiEngine, model } = req.body ?? {};
193
+ const { mode = 'implement', profileName, aiEngine, model, interactive } = req.body ?? {};
193
194
  if (!VALID_MODES.has(mode)) {
194
195
  res.status(400).json({ error: 'mode must be "implement", "batch-implement" or "ultracode"' });
195
196
  return;
@@ -202,6 +203,18 @@ function createRailsRouter() {
202
203
  return;
203
204
  }
204
205
  }
206
+ // Interactive toggle: only valid for ultracode (Claude-only) and only when
207
+ // the feature is enabled. Reject loudly so the client never silently drops it.
208
+ if (interactive === true) {
209
+ if (mode !== 'ultracode') {
210
+ res.status(400).json({ error: 'interactive mode is only available for ultracode rails' });
211
+ return;
212
+ }
213
+ if (!(0, feature_flags_1.isInteractiveJobsEnabled)()) {
214
+ res.status(403).json({ error: 'Interactive jobs are disabled on this server' });
215
+ return;
216
+ }
217
+ }
205
218
  const c = ctx(req);
206
219
  const rail = (0, rails_store_1.getRail)(c.db, railIndex);
207
220
  if (rail.ticketIds.length === 0) {
@@ -267,6 +280,7 @@ function createRailsRouter() {
267
280
  profileName: null,
268
281
  provider: 'claude',
269
282
  ...(ultracodeModel ? { model: ultracodeModel } : {}),
283
+ ...(interactive === true ? { interactive: true } : {}),
270
284
  });
271
285
  jobIds.push(job.id);
272
286
  c.railJobs.set(job.id, { railIndex, mode, ticketIds: [ticketId] });
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ // Shared stream-frame → human-readable display-text extraction.
3
+ //
4
+ // Extracted from queue-manager so the interactive-job session transport can
5
+ // render the SAME log lines QueueManager's one-shot spawn does, without a
6
+ // circular import between the two modules.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.extractDisplayText = extractDisplayText;
9
+ /**
10
+ * Map a parsed provider stream-json frame to the single display line the Job
11
+ * Detail log shows (or null when the frame carries no user-facing text).
12
+ * Handles both Claude `--output-format stream-json` and Codex `exec --json`.
13
+ */
14
+ function extractDisplayText(event) {
15
+ const type = event.type;
16
+ // ── Claude `--output-format stream-json` ───────────────────────────────
17
+ if (type === 'assistant') {
18
+ const content = event.message;
19
+ const texts = (content?.content ?? [])
20
+ .filter((c) => c.type === 'text')
21
+ .map((c) => c.text ?? '');
22
+ return texts.join('') || null;
23
+ }
24
+ if (type === 'tool_use') {
25
+ const name = event.name;
26
+ const input = JSON.stringify(event.input ?? {});
27
+ return `[tool: ${name}] ${input.slice(0, 120)}`;
28
+ }
29
+ if (type === 'tool_result' || type === 'system_prompt' || type === 'user' || type === 'system' || type === 'result') {
30
+ return null;
31
+ }
32
+ // ── Codex `exec --json` event types ───────────────────────────────────
33
+ // Codex shape differs from claude: items are nested under `item` with a
34
+ // discriminator at `item.type`. Without explicit handling the Job Detail
35
+ // log shows only the spawn preamble and exit notice — exactly the
36
+ // "2 / 2 lines" symptom that masks 200k+ tokens of real work.
37
+ if (type === 'item.completed' || type === 'item.started') {
38
+ const item = event.item;
39
+ if (!item)
40
+ return null;
41
+ const itemType = item.type;
42
+ if (itemType === 'agent_message') {
43
+ const text = item.text?.trim();
44
+ return text && text.length > 0 ? text : null;
45
+ }
46
+ if (itemType === 'command_execution') {
47
+ // Only surface the completed line so the log isn't doubled with the
48
+ // matching `item.started` placeholder.
49
+ if (type !== 'item.completed')
50
+ return null;
51
+ const cmd = item.command ?? '';
52
+ const exitCode = item.exit_code;
53
+ const exitStr = typeof exitCode === 'number' ? ` → exit ${exitCode}` : '';
54
+ return `[exec]${exitStr} ${cmd.slice(0, 200)}`;
55
+ }
56
+ if (itemType === 'agent_reasoning') {
57
+ const text = item.text?.trim();
58
+ return text && text.length > 0 ? `[reasoning] ${text.slice(0, 200)}` : null;
59
+ }
60
+ return null;
61
+ }
62
+ if (type === 'thread.started' || type === 'turn.started' || type === 'turn.completed') {
63
+ return null;
64
+ }
65
+ return null;
66
+ }