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.
- package/client/dist/assets/{ActivityFeedPage-DJJlZ3mF.js → ActivityFeedPage-BTYWMRwB.js} +1 -1
- package/client/dist/assets/AgentsPage-BfOCeHHt.js +86 -0
- package/client/dist/assets/{AnalyticsPage-BUd3gWYC.js → AnalyticsPage-AbVXKh9v.js} +1 -1
- package/client/dist/assets/{BarChart-HDe_YoUD.js → BarChart-DlshJN3Z.js} +1 -1
- package/client/dist/assets/CodePage-DJCjDG4I.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-CgvmSvF0.js → DesktopAnalyticsPage-CTqZ9mbB.js} +1 -1
- package/client/dist/assets/DocsDialog-KiJOSRvX.js +11 -0
- package/client/dist/assets/DocsPage-B17CR54A.js +11 -0
- package/client/dist/assets/{ExportDropdown-f4dwQjlT.js → ExportDropdown-BAu6z3b6.js} +1 -1
- package/client/dist/assets/IntegrationsPage-CCG64Q-6.js +3 -0
- package/client/dist/assets/JobDetailPage-BnGJSMiS.js +16 -0
- package/client/dist/assets/JobsPage-B-tn4CIf.js +1 -0
- package/client/dist/assets/dashboard--Ahnvfr3.js +1 -0
- package/client/dist/assets/dashboard-BN1C2pEh.js +1 -0
- package/client/dist/assets/dashboard-BZs_EzAn.js +1 -0
- package/client/dist/assets/dashboard-Bsw44L8_.js +1 -0
- package/client/dist/assets/dashboard-Bw3VECgY.js +1 -0
- package/client/dist/assets/{dashboard-Duo4DDCW.js → dashboard-CuOshSHn.js} +1 -1
- package/client/dist/assets/dashboard-DfouCM3_.js +1 -0
- package/client/dist/assets/dashboard-Pp5hwnZB.js +1 -0
- package/client/dist/assets/{dist-js-COfIfLRE.js → dist-js-B16c3VyT.js} +1 -1
- package/client/dist/assets/{dist-js-CvScGQU_.js → dist-js-P2FkJ6fA.js} +1 -1
- package/client/dist/assets/{index-DGIXKRHE.js → index-AfVF6BgE.js} +34 -34
- package/client/dist/assets/index-NlH5BbXJ.css +2 -0
- package/client/dist/assets/jobs-BGkI19S_.js +1 -0
- package/client/dist/assets/jobs-Brp44JDd.js +1 -0
- package/client/dist/assets/jobs-D93lG6If.js +1 -0
- package/client/dist/assets/jobs-DAF8AGy5.js +1 -0
- package/client/dist/assets/jobs-Db3xrsp_.js +1 -0
- package/client/dist/assets/jobs-Do4Ltqdj.js +1 -0
- package/client/dist/assets/jobs-F5PGJwbW.js +1 -0
- package/client/dist/assets/jobs-fYWWxCUV.js +1 -0
- package/client/dist/assets/{lib-Bro9Z0gp.js → lib-rNNmltMb.js} +1 -1
- package/client/dist/assets/{specs-DicWhvwi.js → specs-B4GuOzuZ.js} +1 -1
- package/client/dist/assets/{specs-CXNQzPk9.js → specs-BVLKe2n5.js} +1 -1
- package/client/dist/assets/{specs-dkro6lSM.js → specs-C62F2CDv.js} +1 -1
- package/client/dist/assets/{specs-4lA_u79w.js → specs-D-Sb6dre.js} +1 -1
- package/client/dist/assets/{specs-DgmyAE3N.js → specs-DFSkAeK8.js} +1 -1
- package/client/dist/assets/{specs-DZCLH2-l.js → specs-DfwDeADE.js} +1 -1
- package/client/dist/assets/{specs-BHjxcjOf.js → specs-VK-zXv7x.js} +1 -1
- package/client/dist/assets/{specs-DFnc2Huj.js → specs-ghyBMnib.js} +1 -1
- package/client/dist/assets/{useProjectCache-D9juBhsO.js → useProjectCache-Cid_GxRM.js} +1 -1
- package/client/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/dist/chat-manager.js +19 -7
- package/server/dist/context-scope.js +29 -8
- package/server/dist/db.js +47 -2
- package/server/dist/feature-flags.js +15 -0
- package/server/dist/interactive-job-session.js +363 -0
- package/server/dist/project-router-jobs.js +42 -0
- package/server/dist/queue-manager.js +214 -54
- package/server/dist/rails-router.js +15 -1
- package/server/dist/util/stream-display.js +66 -0
- package/client/dist/assets/AgentsPage-49JaEDjR.js +0 -86
- package/client/dist/assets/CodePage-CqPPND47.js +0 -2
- package/client/dist/assets/DocsDialog-hHFd3Ejs.js +0 -11
- package/client/dist/assets/DocsPage-B4R1aksg.js +0 -11
- package/client/dist/assets/IntegrationsPage-CX2Ybxx0.js +0 -3
- package/client/dist/assets/JobDetailPage-DN2Jc8Ti.js +0 -16
- package/client/dist/assets/JobsPage-DmdpqijT.js +0 -1
- package/client/dist/assets/dashboard--Y6yzMlf.js +0 -1
- package/client/dist/assets/dashboard--a4-6oYE.js +0 -1
- package/client/dist/assets/dashboard-BiJ3CDTG.js +0 -1
- package/client/dist/assets/dashboard-CiXjk63Z.js +0 -1
- package/client/dist/assets/dashboard-Cx5VjCea.js +0 -1
- package/client/dist/assets/dashboard-D7jg25XR.js +0 -1
- package/client/dist/assets/dashboard-DpGYK2s1.js +0 -1
- package/client/dist/assets/index-DBpvYrDK.css +0 -2
- package/client/dist/assets/jobs-8viuHLDV.js +0 -1
- package/client/dist/assets/jobs-AW2eB5D-.js +0 -1
- package/client/dist/assets/jobs-BSm89DL5.js +0 -1
- package/client/dist/assets/jobs-BZ3sQHjZ.js +0 -1
- package/client/dist/assets/jobs-Bd8AdOTb.js +0 -1
- package/client/dist/assets/jobs-CRtsq_u0.js +0 -1
- package/client/dist/assets/jobs-CSRwFQ6K.js +0 -1
- package/client/dist/assets/jobs-CbEl7WMI.js +0 -1
|
@@ -391,13 +391,23 @@ class ChatManager {
|
|
|
391
391
|
*/
|
|
392
392
|
_buildLightweightSystemPrompt(scope) {
|
|
393
393
|
const name = this._projectName ?? 'this project';
|
|
394
|
-
|
|
394
|
+
// High tier = the user opted into MCP/connectors (Max/Desktop presets). At
|
|
395
|
+
// that tier the agent also has Bash (gh + repo inspection) and MCP tools, so
|
|
396
|
+
// its stance flips from "be minimal" to "verify against the real code before
|
|
397
|
+
// recommending" — this is deterministic per scope, so the prompt stays
|
|
398
|
+
// byte-stable for prompt caching within a given scope.
|
|
399
|
+
const highTier = !!(scope && scope.full && (scope.mcp || scope.userMcp));
|
|
400
|
+
const intro = `You are a focused assistant for the "${name}" specrails project. ` +
|
|
395
401
|
`You have explicit permission to read and write .specrails/local-tickets.json — ` +
|
|
396
402
|
`this is the project's local ticket store managed by Specrails. It is NOT sensitive. ` +
|
|
397
|
-
`When creating or updating tickets, write directly to this JSON file
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
|
|
403
|
+
`When creating or updating tickets, write directly to this JSON file.`;
|
|
404
|
+
const stance = highTier
|
|
405
|
+
? `IMPORTANT: You have read/search tools (Read, Grep, Glob), the GitHub CLI (\`gh\`, already authenticated for this machine — use it to inspect issues, PRs, and CI), and any MCP servers the user enabled. ` +
|
|
406
|
+
`Before recommending a spec, doing gap analysis, or claiming a feature is missing, you MUST INVESTIGATE THE ACTUAL CODEBASE first — grep and read the relevant source (and check \`gh\`/MCP where useful) to confirm the real implementation status. ` +
|
|
407
|
+
`NEVER recommend a spec for something that is already implemented: if the backlog or another spec references a feature, verify it in code before proposing it. Reading code thoroughly is expected and encouraged at this tier — do not guess from ticket titles alone.`
|
|
408
|
+
: `IMPORTANT: Be efficient. Minimize tool calls. Only read files that are directly relevant. ` +
|
|
409
|
+
`Do not explore broadly — focus on the specific task.`;
|
|
410
|
+
const scopedBase = `${intro}\n\n${stance}\n\n` +
|
|
401
411
|
`When "Specrails Tickets" or "OpenSpec Specs" sections are present below, treat them as authoritative project context. ` +
|
|
402
412
|
`For roadmap-style requests like "suggest the next best spec", ground the answer in that context, avoid duplicates, and propose one concrete next spec instead of generic directions.`;
|
|
403
413
|
if (!scope || !this._cwd)
|
|
@@ -495,8 +505,10 @@ class ChatManager {
|
|
|
495
505
|
: 'chat-turn';
|
|
496
506
|
// Translate the per-conversation Explore scope into provider-native
|
|
497
507
|
// tool-gating flags. `toolFlagsForScope` emits claude-shape argv
|
|
498
|
-
// (`--
|
|
499
|
-
//
|
|
508
|
+
// (`--tools …` to restrict the read-only tiers, or `--disallowedTools …` at
|
|
509
|
+
// the high MCP tier where Bash + MCP tools must stay callable); codex's
|
|
510
|
+
// `exec` would reject those with an "unexpected argument" error and crash
|
|
511
|
+
// the turn. The scope's tool
|
|
500
512
|
// gating is therefore claude-only today — codex inherits its sandbox
|
|
501
513
|
// and approval policy from the project's `.codex/config.toml` (or the
|
|
502
514
|
// `-c sandbox_mode=` override the adapter already attaches on resume).
|
|
@@ -262,16 +262,37 @@ function buildScopedSystemPromptPrefix(scope, projectPath) {
|
|
|
262
262
|
return sections.join('\n\n');
|
|
263
263
|
}
|
|
264
264
|
// Compute the claude CLI tool flags for the given scope.
|
|
265
|
-
// We use `--tools` (whitelist) instead of `--disallowedTools` because
|
|
266
|
-
// `--dangerously-skip-permissions` can bypass disallow filters in some CLI
|
|
267
|
-
// versions; an explicit whitelist is the most reliable lockdown.
|
|
268
265
|
//
|
|
269
|
-
// -
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
266
|
+
// The base read-only Explore tiers use `--tools Read,Grep,Glob` (a built-in
|
|
267
|
+
// toolkit RESTRICTION — NOT a permission allow-list). This is the most reliable
|
|
268
|
+
// read-only lockdown because `--dangerously-skip-permissions` (in COMMON_FLAGS)
|
|
269
|
+
// can bypass `--disallowedTools` filters. Verified against claude 2.1.177:
|
|
270
|
+
// - `--tools Read,Grep,Glob` removes Bash AND MCP tools from the toolkit, so
|
|
271
|
+
// `gh` and any `mcp__*` tools are uncallable (the "only local read access"
|
|
272
|
+
// symptom users hit when they enable approved MCPs at this tier).
|
|
273
|
+
//
|
|
274
|
+
// HIGH TIER (full AND an MCP toggle on — the Max/Desktop presets, i.e. the user
|
|
275
|
+
// explicitly opted into MCP/connectors): we cannot enumerate Claude.ai connector
|
|
276
|
+
// servers to put them in an allow-list, so we keep the full `--tools default`
|
|
277
|
+
// toolkit + skip-permissions (which makes ALL MCP servers — file, plugin, and
|
|
278
|
+
// connector — callable) and merely DISALLOW the GUI file-writers. This is a
|
|
279
|
+
// deliberate capability bump: it also exposes Bash (so the agent can run `gh`
|
|
280
|
+
// with the user's local auth, and inspect the repo via shell). Bash can write
|
|
281
|
+
// to disk, so this tier is NOT a hard read-only sandbox — the Explore system
|
|
282
|
+
// prompt carries the non-destructive stance, and the tier is opt-in.
|
|
283
|
+
//
|
|
284
|
+
// - full && (mcp || userMcp) → --disallowedTools Write,Edit,NotebookEdit
|
|
285
|
+
// (default toolkit: Read/Grep/Glob/Bash/WebFetch +
|
|
286
|
+
// all loaded MCP tools; file-writer tools removed)
|
|
287
|
+
// - full (no MCP toggle) → --tools Read,Grep,Glob (read-only, no Bash/MCP)
|
|
288
|
+
// - !full → --tools __none__ (a non-existent tool name; the
|
|
289
|
+
// empty string `""` is silently dropped by some CLI
|
|
290
|
+
// versions and falls back to the default set, so we
|
|
291
|
+
// use a sentinel to disable all tools).
|
|
274
292
|
function toolFlagsForScope(scope) {
|
|
293
|
+
if (scope.full && (scope.mcp || scope.userMcp)) {
|
|
294
|
+
return { args: ['--disallowedTools', 'Write,Edit,NotebookEdit'] };
|
|
295
|
+
}
|
|
275
296
|
if (scope.full) {
|
|
276
297
|
return { args: ['--tools', 'Read,Grep,Glob'] };
|
|
277
298
|
}
|
package/server/dist/db.js
CHANGED
|
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.DEFAULT_ULTRACODE_PRE_PROMPT = void 0;
|
|
7
7
|
exports.initDb = initDb;
|
|
8
8
|
exports.createJob = createJob;
|
|
9
|
+
exports.accumulateInteractiveTurn = accumulateInteractiveTurn;
|
|
10
|
+
exports.finalizeInteractiveJob = finalizeInteractiveJob;
|
|
9
11
|
exports.finishJob = finishJob;
|
|
10
12
|
exports.appendEvent = appendEvent;
|
|
11
13
|
exports.upsertPhase = upsertPhase;
|
|
@@ -639,6 +641,18 @@ const MIGRATIONS = [
|
|
|
639
641
|
// Column may already exist on a partially-migrated DB — no-op.
|
|
640
642
|
}
|
|
641
643
|
},
|
|
644
|
+
// Migration 32: jobs.interactive — 1 when the job is an interactive persistent
|
|
645
|
+
// ultracode session (the user sends multiple prompts across turns; the job
|
|
646
|
+
// stays 'running' until an explicit finalize, at which point every turn's real
|
|
647
|
+
// tokens/cost/num_turns are already summed into the row and status flips to
|
|
648
|
+
// 'completed'); 0 (default) for standard autonomous jobs. Additive + idempotent
|
|
649
|
+
// (guarded by PRAGMA table_info, mirroring migrations 18–26).
|
|
650
|
+
(db) => {
|
|
651
|
+
const cols = db.prepare(`PRAGMA table_info(jobs)`).all().map((r) => r.name);
|
|
652
|
+
if (!cols.includes('interactive')) {
|
|
653
|
+
db.exec(`ALTER TABLE jobs ADD COLUMN interactive INTEGER NOT NULL DEFAULT 0`);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
642
656
|
];
|
|
643
657
|
function applyMigrations(db) {
|
|
644
658
|
// Ensure the migrations table exists (migration 1 creates it, but we need
|
|
@@ -700,8 +714,39 @@ function initDb(dbPath) {
|
|
|
700
714
|
function createJob(db, job) {
|
|
701
715
|
// INSERT OR IGNORE handles the case where the job row already exists (restored from DB
|
|
702
716
|
// after server restart). The UPDATE that follows always sets status and started_at.
|
|
703
|
-
db.prepare('INSERT OR IGNORE INTO jobs (id, command, started_at, status, priority, depends_on_job_id, pipeline_id) VALUES (?, ?, ?, ?, ?, ?, ?)').run(job.id, job.command, job.started_at, 'running', job.priority ?? 'normal', job.depends_on_job_id ?? null, job.pipeline_id ?? null);
|
|
704
|
-
db.prepare('UPDATE jobs SET status = ?, started_at = ? WHERE id = ?').run('running', job.started_at, job.id);
|
|
717
|
+
db.prepare('INSERT OR IGNORE INTO jobs (id, command, started_at, status, priority, depends_on_job_id, pipeline_id, interactive) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(job.id, job.command, job.started_at, 'running', job.priority ?? 'normal', job.depends_on_job_id ?? null, job.pipeline_id ?? null, job.interactive ? 1 : 0);
|
|
718
|
+
db.prepare('UPDATE jobs SET status = ?, started_at = ?, interactive = ? WHERE id = ?').run('running', job.started_at, job.interactive ? 1 : 0, job.id);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Add one completed interactive turn's REAL usage into the job row. Token/cost/
|
|
722
|
+
* turn columns accumulate (COALESCE so the first turn starts from a clean base);
|
|
723
|
+
* model + session_id are stamped from the first turn that reports them. The job
|
|
724
|
+
* stays 'running' — only finalizeInteractiveJob flips the terminal status. This
|
|
725
|
+
* keeps the live Job Detail totals honest (sum of completed turns, never an
|
|
726
|
+
* estimate) between turns.
|
|
727
|
+
*/
|
|
728
|
+
function accumulateInteractiveTurn(db, jobId, turn) {
|
|
729
|
+
db.prepare(`
|
|
730
|
+
UPDATE jobs SET
|
|
731
|
+
tokens_in = COALESCE(tokens_in, 0) + ?,
|
|
732
|
+
tokens_out = COALESCE(tokens_out, 0) + ?,
|
|
733
|
+
tokens_cache_read = COALESCE(tokens_cache_read, 0) + ?,
|
|
734
|
+
tokens_cache_create = COALESCE(tokens_cache_create, 0) + ?,
|
|
735
|
+
total_cost_usd = COALESCE(total_cost_usd, 0) + ?,
|
|
736
|
+
num_turns = COALESCE(num_turns, 0) + ?,
|
|
737
|
+
total_cost_usd_estimated = 0,
|
|
738
|
+
model = COALESCE(model, ?),
|
|
739
|
+
session_id = COALESCE(?, session_id)
|
|
740
|
+
WHERE id = ?
|
|
741
|
+
`).run(turn.tokens_in, turn.tokens_out, turn.tokens_cache_read, turn.tokens_cache_create, turn.total_cost_usd, turn.num_turns, turn.model ?? null, turn.session_id ?? null, jobId);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Flip an interactive job to its terminal status (completed on finalize, failed
|
|
745
|
+
* on crash) and stamp finished_at. Token/cost/turn columns are left untouched —
|
|
746
|
+
* they were already accumulated turn-by-turn via accumulateInteractiveTurn.
|
|
747
|
+
*/
|
|
748
|
+
function finalizeInteractiveJob(db, jobId, status) {
|
|
749
|
+
db.prepare('UPDATE jobs SET status = ?, finished_at = ? WHERE id = ?').run(status, new Date().toISOString(), jobId);
|
|
705
750
|
}
|
|
706
751
|
function finishJob(db, jobId, result) {
|
|
707
752
|
db.prepare(`
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.isCodeExplorerEnabled = isCodeExplorerEnabled;
|
|
4
4
|
exports.isBrowserCaptureEnabled = isBrowserCaptureEnabled;
|
|
5
5
|
exports.isJiraEnabled = isJiraEnabled;
|
|
6
|
+
exports.isInteractiveJobsEnabled = isInteractiveJobsEnabled;
|
|
6
7
|
function isCodeExplorerEnabled() {
|
|
7
8
|
return process.env.SPECRAILS_CODE_EXPLORER !== 'false';
|
|
8
9
|
}
|
|
@@ -26,3 +27,17 @@ function isBrowserCaptureEnabled() {
|
|
|
26
27
|
function isJiraEnabled() {
|
|
27
28
|
return process.env.SPECRAILS_JIRA_SECTION !== 'false';
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Interactive ultracode jobs: when launched with the rail's "Interactive"
|
|
32
|
+
* toggle, an ultracode (Claude-only) job becomes a persistent chat session —
|
|
33
|
+
* the user sends multiple prompts across turns, the job stays resident until an
|
|
34
|
+
* explicit "Finalize Job" action, and every turn's real token spend is summed
|
|
35
|
+
* into the single job row. Server-side default ON; set
|
|
36
|
+
* SPECRAILS_INTERACTIVE_JOBS="false" to reject the toggle + the per-job
|
|
37
|
+
* message/finalize routes (emergency rollback). Inert unless a launch actually
|
|
38
|
+
* sets interactive=true, so default-on is safe. The client gates separately on
|
|
39
|
+
* VITE_FEATURE_INTERACTIVE_JOBS.
|
|
40
|
+
*/
|
|
41
|
+
function isInteractiveJobsEnabled() {
|
|
42
|
+
return process.env.SPECRAILS_INTERACTIVE_JOBS !== 'false';
|
|
43
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Interactive ultracode job sessions (resident persistent-stdin transport).
|
|
3
|
+
//
|
|
4
|
+
// A standard ultracode job spawns `claude -p <prompt>` once and settles when the
|
|
5
|
+
// child closes. An INTERACTIVE ultracode job instead keeps ONE `claude -p
|
|
6
|
+
// --input-format stream-json` child resident across many user turns (the same
|
|
7
|
+
// transport ExploreStdinSessions uses for Explore chat): each user prompt is a
|
|
8
|
+
// newline-delimited stream-json message written to stdin, the agent works, and
|
|
9
|
+
// the turn ends on a `result` event WITHOUT killing the child. The session stays
|
|
10
|
+
// alive until the user finalizes (SIGTERM) — at which point QueueManager flips
|
|
11
|
+
// the job to a terminal status. Every turn's REAL token usage is summed into the
|
|
12
|
+
// job row as it completes, so the live Job Detail totals are honest (never an
|
|
13
|
+
// estimate) and the finalized job carries the full conversation's spend.
|
|
14
|
+
//
|
|
15
|
+
// This module owns the transport + per-turn streaming/persistence/accounting.
|
|
16
|
+
// QueueManager owns spawn-arg construction and the terminal settle (slot release,
|
|
17
|
+
// rail/ticket completion, ai_invocations) via the onSettle callback.
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.InteractiveJobSession = void 0;
|
|
20
|
+
const node_readline_1 = require("node:readline");
|
|
21
|
+
const cli_prompt_1 = require("./util/cli-prompt");
|
|
22
|
+
const explore_stdin_session_1 = require("./explore-stdin-session");
|
|
23
|
+
const result_event_1 = require("./result-event");
|
|
24
|
+
const db_1 = require("./db");
|
|
25
|
+
const stream_display_1 = require("./util/stream-display");
|
|
26
|
+
function zeroUsage() {
|
|
27
|
+
return {
|
|
28
|
+
tokens_in: 0,
|
|
29
|
+
tokens_out: 0,
|
|
30
|
+
tokens_cache_read: 0,
|
|
31
|
+
tokens_cache_create: 0,
|
|
32
|
+
total_cost_usd: 0,
|
|
33
|
+
num_turns: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** SIGTERM → SIGKILL escalation window on finalize (ms). */
|
|
37
|
+
const FINALIZE_KILL_GRACE_MS = 2000;
|
|
38
|
+
class InteractiveJobSession {
|
|
39
|
+
_jobId;
|
|
40
|
+
_projectId;
|
|
41
|
+
_db;
|
|
42
|
+
_adapter;
|
|
43
|
+
_broadcast;
|
|
44
|
+
_onSettle;
|
|
45
|
+
_spawn;
|
|
46
|
+
_child = null;
|
|
47
|
+
_stdoutReader = null;
|
|
48
|
+
_stderrReader = null;
|
|
49
|
+
_eventSeq = 0;
|
|
50
|
+
_streaming = false;
|
|
51
|
+
/** True between writing a turn to stdin and receiving its `result` event.
|
|
52
|
+
* Guards _onTurnResult against double-counting a duplicate `result` frame. */
|
|
53
|
+
_awaitingResult = false;
|
|
54
|
+
_pending = [];
|
|
55
|
+
_turnEvents = [];
|
|
56
|
+
_accum = zeroUsage();
|
|
57
|
+
_model = null;
|
|
58
|
+
_sessionId = null;
|
|
59
|
+
_finalizing = false;
|
|
60
|
+
_settled = false;
|
|
61
|
+
_disposed = false;
|
|
62
|
+
_killTimer = null;
|
|
63
|
+
constructor(deps) {
|
|
64
|
+
this._jobId = deps.jobId;
|
|
65
|
+
this._projectId = deps.projectId;
|
|
66
|
+
this._db = deps.db;
|
|
67
|
+
this._adapter = deps.adapter;
|
|
68
|
+
this._broadcast = deps.broadcast;
|
|
69
|
+
this._onSettle = deps.onSettle;
|
|
70
|
+
this._spawn = deps.spawn ?? cli_prompt_1.spawnAiCli;
|
|
71
|
+
}
|
|
72
|
+
/** Spawn the resident child and run the first turn (the ultracode prompt). */
|
|
73
|
+
start(spec, firstPrompt) {
|
|
74
|
+
const child = this._spawn(spec.binary, spec.args, {
|
|
75
|
+
env: spec.env ?? process.env,
|
|
76
|
+
// stdin MUST be piped — it is the per-turn transport.
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
cwd: spec.cwd,
|
|
79
|
+
});
|
|
80
|
+
this._child = child;
|
|
81
|
+
// Absorb spawn 'error' (e.g. ENOENT) so it does not crash the process; the
|
|
82
|
+
// 'close' that follows settles the job as crashed through _handleClose.
|
|
83
|
+
child.on('error', (err) => {
|
|
84
|
+
console.error(`[interactive-job] spawn failed for ${this._jobId}: ${err.message}`);
|
|
85
|
+
});
|
|
86
|
+
if (child.stdout) {
|
|
87
|
+
this._stdoutReader = (0, node_readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
88
|
+
this._stdoutReader.on('line', (line) => this._handleStdoutLine(line));
|
|
89
|
+
}
|
|
90
|
+
if (child.stderr) {
|
|
91
|
+
this._stderrReader = (0, node_readline_1.createInterface)({ input: child.stderr, crlfDelay: Infinity });
|
|
92
|
+
this._stderrReader.on('line', (line) => this._handleStderrLine(line));
|
|
93
|
+
}
|
|
94
|
+
child.on('close', (code) => this._handleClose(code));
|
|
95
|
+
this.send(firstPrompt);
|
|
96
|
+
}
|
|
97
|
+
/** Accept a user prompt. Echoed immediately to the in-job chat; written to the
|
|
98
|
+
* child now if idle, else queued and fed when the active turn's `result`
|
|
99
|
+
* fires. Returns false if the session is gone. */
|
|
100
|
+
send(text) {
|
|
101
|
+
if (this._disposed || this._finalizing || !this._child)
|
|
102
|
+
return false;
|
|
103
|
+
const queued = this._streaming;
|
|
104
|
+
// Surface the user turn in the transcript via the existing `log` channel so
|
|
105
|
+
// it both renders live (the client's 'log' handler) and survives a reload
|
|
106
|
+
// (persisted as a log event, picked up by GET /jobs/:id). The 🧑 prefix marks
|
|
107
|
+
// it as the user's prompt amid the agent's streamed work.
|
|
108
|
+
const line = `🧑 ${text}`;
|
|
109
|
+
this._persistLog('stdout', line);
|
|
110
|
+
this._emitLog('stdout', line);
|
|
111
|
+
// Control signal for the in-job chat UI (streaming-state / queued hint).
|
|
112
|
+
this._broadcast({
|
|
113
|
+
type: 'job.turn_user',
|
|
114
|
+
projectId: this._projectId,
|
|
115
|
+
jobId: this._jobId,
|
|
116
|
+
text,
|
|
117
|
+
queued,
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
if (queued) {
|
|
121
|
+
this._pending.push(text);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this._writeTurn(text);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
isStreaming() {
|
|
129
|
+
return this._streaming;
|
|
130
|
+
}
|
|
131
|
+
getTotals() {
|
|
132
|
+
return { ...this._accum };
|
|
133
|
+
}
|
|
134
|
+
/** User-initiated end: SIGTERM the child (SIGKILL after a grace window). The
|
|
135
|
+
* subsequent 'close' settles the job as 'finalized'. Idempotent. */
|
|
136
|
+
finalize() {
|
|
137
|
+
if (this._finalizing || this._settled)
|
|
138
|
+
return;
|
|
139
|
+
this._finalizing = true;
|
|
140
|
+
const child = this._child;
|
|
141
|
+
if (!child || child.killed || !child.pid) {
|
|
142
|
+
this._settle('finalized');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
child.kill('SIGTERM');
|
|
147
|
+
}
|
|
148
|
+
catch { /* already gone */ }
|
|
149
|
+
this._killTimer = setTimeout(() => {
|
|
150
|
+
try {
|
|
151
|
+
if (this._child && !this._child.killed)
|
|
152
|
+
this._child.kill('SIGKILL');
|
|
153
|
+
}
|
|
154
|
+
catch { /* gone */ }
|
|
155
|
+
}, FINALIZE_KILL_GRACE_MS);
|
|
156
|
+
}
|
|
157
|
+
/** Teardown without settling (project removal / shutdown). */
|
|
158
|
+
dispose() {
|
|
159
|
+
if (this._disposed)
|
|
160
|
+
return;
|
|
161
|
+
this._disposed = true;
|
|
162
|
+
this._clearKillTimer();
|
|
163
|
+
this._closeReaders();
|
|
164
|
+
try {
|
|
165
|
+
if (this._child && !this._child.killed)
|
|
166
|
+
this._child.kill('SIGTERM');
|
|
167
|
+
}
|
|
168
|
+
catch { /* gone */ }
|
|
169
|
+
this._child = null;
|
|
170
|
+
}
|
|
171
|
+
// ─── internals ─────────────────────────────────────────────────────────────
|
|
172
|
+
_writeTurn(text) {
|
|
173
|
+
this._streaming = true;
|
|
174
|
+
this._awaitingResult = true;
|
|
175
|
+
this._turnEvents = [];
|
|
176
|
+
const child = this._child;
|
|
177
|
+
if (!child || !child.stdin || child.stdin.destroyed)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
child.stdin.write((0, explore_stdin_session_1.frameStreamJsonUserMessage)(text));
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error('[interactive-job] stdin write failed:', err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
_handleStdoutLine(line) {
|
|
187
|
+
let parsed = null;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(line);
|
|
190
|
+
}
|
|
191
|
+
catch { /* plain text */ }
|
|
192
|
+
const adapterEv = this._adapter.parseStreamLine(line);
|
|
193
|
+
if (adapterEv)
|
|
194
|
+
this._turnEvents.push(adapterEv);
|
|
195
|
+
if (parsed) {
|
|
196
|
+
const eventType = parsed.type ?? 'unknown';
|
|
197
|
+
this._persistEvent(eventType, line);
|
|
198
|
+
this._broadcast({
|
|
199
|
+
type: 'event',
|
|
200
|
+
jobId: this._jobId,
|
|
201
|
+
event_type: eventType,
|
|
202
|
+
source: 'stdout',
|
|
203
|
+
payload: line,
|
|
204
|
+
timestamp: new Date().toISOString(),
|
|
205
|
+
seq: this._eventSeq - 1,
|
|
206
|
+
});
|
|
207
|
+
if (eventType === 'result') {
|
|
208
|
+
this._onTurnResult(parsed);
|
|
209
|
+
}
|
|
210
|
+
const displayText = (0, stream_display_1.extractDisplayText)(parsed);
|
|
211
|
+
if (displayText !== null) {
|
|
212
|
+
this._persistLog('stdout', displayText);
|
|
213
|
+
this._emitLog('stdout', displayText);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
this._persistLog('stdout', line);
|
|
218
|
+
if (adapterEv?.kind === 'text-delta') {
|
|
219
|
+
this._emitLog('stdout', adapterEv.text);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
this._emitLog('stdout', line);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
_handleStderrLine(line) {
|
|
227
|
+
this._persistLog('stderr', line);
|
|
228
|
+
this._emitLog('stderr', line);
|
|
229
|
+
}
|
|
230
|
+
_onTurnResult(_parsed) {
|
|
231
|
+
// Guard against a duplicate `result` frame for the same turn — without it a
|
|
232
|
+
// second result would double-count this turn's tokens into _accum + the row.
|
|
233
|
+
if (!this._awaitingResult)
|
|
234
|
+
return;
|
|
235
|
+
this._awaitingResult = false;
|
|
236
|
+
this._streaming = false;
|
|
237
|
+
const { result: normalised } = (0, result_event_1.finaliseInvocationResult)(this._adapter, this._turnEvents, {});
|
|
238
|
+
this._accum.tokens_in += normalised.tokens_in ?? 0;
|
|
239
|
+
this._accum.tokens_out += normalised.tokens_out ?? 0;
|
|
240
|
+
this._accum.tokens_cache_read += normalised.tokens_cache_read ?? 0;
|
|
241
|
+
this._accum.tokens_cache_create += normalised.tokens_cache_create ?? 0;
|
|
242
|
+
this._accum.total_cost_usd += normalised.total_cost_usd ?? 0;
|
|
243
|
+
this._accum.num_turns += normalised.num_turns ?? 1;
|
|
244
|
+
if (!this._model && normalised.model)
|
|
245
|
+
this._model = normalised.model;
|
|
246
|
+
if (normalised.session_id)
|
|
247
|
+
this._sessionId = normalised.session_id;
|
|
248
|
+
if (this._db) {
|
|
249
|
+
try {
|
|
250
|
+
(0, db_1.accumulateInteractiveTurn)(this._db, this._jobId, {
|
|
251
|
+
tokens_in: normalised.tokens_in ?? 0,
|
|
252
|
+
tokens_out: normalised.tokens_out ?? 0,
|
|
253
|
+
tokens_cache_read: normalised.tokens_cache_read ?? 0,
|
|
254
|
+
tokens_cache_create: normalised.tokens_cache_create ?? 0,
|
|
255
|
+
total_cost_usd: normalised.total_cost_usd ?? 0,
|
|
256
|
+
num_turns: normalised.num_turns ?? 1,
|
|
257
|
+
model: normalised.model,
|
|
258
|
+
session_id: normalised.session_id,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error('[interactive-job] accumulate turn failed:', err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this._broadcast({
|
|
266
|
+
type: 'job.turn_done',
|
|
267
|
+
projectId: this._projectId,
|
|
268
|
+
jobId: this._jobId,
|
|
269
|
+
totals: { ...this._accum },
|
|
270
|
+
timestamp: new Date().toISOString(),
|
|
271
|
+
});
|
|
272
|
+
// Feed the next queued prompt (if any) now that the turn is idle.
|
|
273
|
+
if (!this._finalizing && this._pending.length > 0) {
|
|
274
|
+
const next = this._pending.shift();
|
|
275
|
+
this._writeTurn(next);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
_persistEvent(eventType, payload) {
|
|
279
|
+
if (!this._db)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
(0, db_1.appendEvent)(this._db, this._jobId, this._eventSeq++, {
|
|
283
|
+
event_type: eventType,
|
|
284
|
+
source: 'stdout',
|
|
285
|
+
payload,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
console.error('[interactive-job] persist event failed:', err);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
_persistLog(source, line) {
|
|
293
|
+
if (!this._db)
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
(0, db_1.appendEvent)(this._db, this._jobId, this._eventSeq++, {
|
|
297
|
+
event_type: 'log',
|
|
298
|
+
source,
|
|
299
|
+
payload: JSON.stringify({ line }),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
console.error('[interactive-job] persist log failed:', err);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
_emitLog(source, line) {
|
|
307
|
+
this._broadcast({
|
|
308
|
+
type: 'log',
|
|
309
|
+
source,
|
|
310
|
+
line,
|
|
311
|
+
timestamp: new Date().toISOString(),
|
|
312
|
+
processId: this._jobId,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
_handleClose(_code) {
|
|
316
|
+
if (this._disposed || this._settled)
|
|
317
|
+
return;
|
|
318
|
+
this._settle(this._finalizing ? 'finalized' : 'crashed');
|
|
319
|
+
}
|
|
320
|
+
_settle(reason) {
|
|
321
|
+
if (this._settled)
|
|
322
|
+
return;
|
|
323
|
+
this._settled = true;
|
|
324
|
+
this._streaming = false;
|
|
325
|
+
this._awaitingResult = false;
|
|
326
|
+
this._clearKillTimer();
|
|
327
|
+
this._closeReaders();
|
|
328
|
+
// The child is gone — any prompts still queued (turn died without a `result`,
|
|
329
|
+
// or the user finalized mid-turn) can never run. Surface them in the
|
|
330
|
+
// transcript instead of dropping them silently.
|
|
331
|
+
if (this._pending.length > 0) {
|
|
332
|
+
const note = `⚠️ ${this._pending.length} queued prompt(s) were not sent — the session ended.`;
|
|
333
|
+
this._persistLog('stderr', note);
|
|
334
|
+
this._emitLog('stderr', note);
|
|
335
|
+
this._pending = [];
|
|
336
|
+
}
|
|
337
|
+
this._onSettle({
|
|
338
|
+
reason,
|
|
339
|
+
totals: { ...this._accum },
|
|
340
|
+
model: this._model,
|
|
341
|
+
sessionId: this._sessionId,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
_clearKillTimer() {
|
|
345
|
+
if (this._killTimer !== null) {
|
|
346
|
+
clearTimeout(this._killTimer);
|
|
347
|
+
this._killTimer = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
_closeReaders() {
|
|
351
|
+
try {
|
|
352
|
+
this._stdoutReader?.close();
|
|
353
|
+
}
|
|
354
|
+
catch { /* best-effort */ }
|
|
355
|
+
try {
|
|
356
|
+
this._stderrReader?.close();
|
|
357
|
+
}
|
|
358
|
+
catch { /* best-effort */ }
|
|
359
|
+
this._stdoutReader = null;
|
|
360
|
+
this._stderrReader = null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
exports.InteractiveJobSession = InteractiveJobSession;
|
|
@@ -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)) {
|