@ai-setting/roy-plugin-task-show 0.6.11 → 0.8.4

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 (43) hide show
  1. package/README.md +121 -1
  2. package/dist/cli-tasks-adapter.d.ts +142 -0
  3. package/dist/cli-tasks-adapter.d.ts.map +1 -0
  4. package/dist/cli-tasks-adapter.js +379 -0
  5. package/dist/cli-tasks-adapter.js.map +1 -0
  6. package/dist/cli-tasks-tree-adapter.d.ts +143 -0
  7. package/dist/cli-tasks-tree-adapter.d.ts.map +1 -0
  8. package/dist/cli-tasks-tree-adapter.js +400 -0
  9. package/dist/cli-tasks-tree-adapter.js.map +1 -0
  10. package/dist/operations-cache.d.ts +44 -0
  11. package/dist/operations-cache.d.ts.map +1 -0
  12. package/dist/operations-cache.js +103 -0
  13. package/dist/operations-cache.js.map +1 -0
  14. package/dist/plugin.d.ts +14 -0
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +82 -0
  17. package/dist/plugin.js.map +1 -1
  18. package/dist/server.d.ts +30 -0
  19. package/dist/server.d.ts.map +1 -1
  20. package/dist/server.js +289 -89
  21. package/dist/server.js.map +1 -1
  22. package/dist/task-detail-mermaid.d.ts +171 -0
  23. package/dist/task-detail-mermaid.d.ts.map +1 -0
  24. package/dist/task-detail-mermaid.js +697 -0
  25. package/dist/task-detail-mermaid.js.map +1 -0
  26. package/dist/task-operations-html.d.ts +50 -0
  27. package/dist/task-operations-html.d.ts.map +1 -0
  28. package/dist/task-operations-html.js +187 -0
  29. package/dist/task-operations-html.js.map +1 -0
  30. package/dist/tasks-tree-cache.d.ts +44 -0
  31. package/dist/tasks-tree-cache.d.ts.map +1 -0
  32. package/dist/tasks-tree-cache.js +111 -0
  33. package/dist/tasks-tree-cache.js.map +1 -0
  34. package/dist/types.d.ts +16 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/package.json +6 -1
  38. package/plugin.json +2 -2
  39. package/public/app.js +534 -34
  40. package/public/index.html +54 -21
  41. package/public/style.css +697 -1
  42. package/public/task-operations.js +290 -0
  43. package/public/tasks-tree.js +449 -0
package/README.md CHANGED
@@ -23,6 +23,14 @@ When loaded into a `roy-agent` host, this plugin:
23
23
  4. Serves the visualization on a local HTTP server (default
24
24
  `http://127.0.0.1:7788/`) — index page, per-task detail page, and the
25
25
  `/api/events` SSE stream.
26
+ 5. **v0.7.0+ — Task lifecycle pipeline.** The per-task detail page now
27
+ also renders a vertical timeline of the task's own operation history
28
+ (`create`, `progress`, `milestone`, `problem`, `solution`, `decision`,
29
+ `review`, `completed`). The data comes from `roy-agent tasks get <id>
30
+ --operations --json`, served by a new endpoint `GET
31
+ /api/tasks/:id/operations`. See
32
+ [Task lifecycle pipeline (v0.7.0+)](#task-lifecycle-pipeline-v070) for
33
+ details.
26
34
 
27
35
  The result: every task gets a clickable link the user can open in a
28
36
  browser. Once open, the page **stays in sync** with the running agent
@@ -224,6 +232,31 @@ healthy.
224
232
 
225
233
  ## Changelog
226
234
 
235
+ ### v0.6.12 — Task lifecycle pipeline integration fix (2026-07-24)
236
+
237
+ **For end users**: `npm install` as usual — no host-side change required.
238
+ Re-published as a patch release to fix a regression in the v0.7.0-pre
239
+ "Task lifecycle pipeline" feature where the page rendered the pipeline
240
+ panel but never actually populated it with operations.
241
+
242
+ - `renderOperationsPipeline(input)` now accepts `input.taskId` and emits
243
+ `data-task-id="<id>"` on the `<section data-pipeline-root>` element
244
+ (empty, populated, and error states). Non-numeric ids are silently
245
+ dropped, so bad input cannot break HTML attribute quoting.
246
+ - `renderTaskPage()` in `src/server.ts` now passes `taskId: session.taskId`
247
+ so every `/task/<id>` response carries the attribute and the client-side
248
+ `boot()` no longer silently bails out.
249
+ - Client-side defense in depth: `boot()` in `public/task-operations.js`
250
+ falls back to `document.body.dataset.taskId` when the section is missing
251
+ the attribute, and `renderPipelineHtml` / `renderErrorHtml` preserve
252
+ the attribute across `swapIn()` calls.
253
+
254
+ 12 new TDD cases in `test/task-operations-browser-integration.test.ts`
255
+ (happy-dom browser integration + unit + server boot). New
256
+ `scripts/verify-task-operations-browser.ts` boots a real Playwright
257
+ Chromium against `TaskShowServer` + the real `roy-agent` CLI and asserts
258
+ 7 op-node cards are rendered for Task #2388. All 161 tests pass.
259
+
227
260
  ### v0.6.11 — Mermaid SVG regression fix (2026-07-24)
228
261
 
229
262
  **For end users**: `npm install` as usual — no host-side change required.
@@ -713,4 +746,91 @@ broadcast.
713
746
 
714
747
  ## License
715
748
 
716
- MIT
749
+ MIT
750
+ ## Task lifecycle pipeline (v0.7.0+)
751
+
752
+ In addition to the tool-call flow (the existing Mermaid diagram), every
753
+ `/task/:id` page now includes a **Task lifecycle pipeline** section that
754
+ shows the task's own operation history — the create / progress / milestone /
755
+ problem / solution / decision / review / completed events that the
756
+ `roy-agent tasks` CLI records for that task.
757
+
758
+ ### Where the data comes from
759
+
760
+ The plugin spawns `roy-agent tasks get <id> --operations --json` (via
761
+ discrete argv — no shell string), parses the `{ task, operations }`
762
+ envelope, and serves a stable schema from the new endpoint:
763
+
764
+ ```
765
+ GET /api/tasks/:id/operations
766
+ → 200 {
767
+ task: { id, title, status, priority, type, progress?, createdAt, updatedAt, tags, projectPath? },
768
+ operations: [
769
+ { id, sequence, milestoneType, title, description, timestamp, sessionShort }
770
+ ],
771
+ fetchedAt: ISO8601,
772
+ stale: boolean
773
+ }
774
+ → 404 { error: "not_found", id } // CLI reported "Task not found"
775
+ → 400 { error: "bad_id", id } // invalid task id
776
+ → 503 { error: "cli_failed", id } // CLI exited with non-zero status
777
+ → 500 { error: "internal_error", id } // unexpected exception
778
+ ```
779
+
780
+ The CLI is invoked safely:
781
+
782
+ - Arg array form: `["node", "<cliPath>", "tasks", "get", "<id>",
783
+ "--operations", "--json"]`. Shell metacharacters in the task id are
784
+ never interpreted.
785
+ - Wall-clock timeout (`cliTimeoutMs`, default 5 s).
786
+ - Max stdout bytes (`cliMaxBytes`, default 1 MiB).
787
+ - Stderr is captured so the CLI's `✗ Task not found: <id>` message is
788
+ mapped to HTTP 404.
789
+
790
+ ### Caching & dedup
791
+
792
+ A TTL cache (default 5 s) coalesces concurrent requests for the same
793
+ task id and limits how often the plugin spawns the host CLI. Stale
794
+ entries are served with `stale: true` while a background refresh runs.
795
+
796
+ ### Polling
797
+
798
+ For active tasks, the page polls `/api/tasks/:id/operations` every 5 s.
799
+ The client merges new operations into the existing timeline by id
800
+ (idempotent — no duplicates) and stops polling once the task reaches a
801
+ terminal status (`completed` / `failed` / `cancelled`). On any CLI
802
+ failure the page shows a "Retry" button and applies exponential backoff
803
+ up to 30 s.
804
+
805
+ ### Security
806
+
807
+ - Task ids are validated as positive integers in the safe range.
808
+ - All operation titles, descriptions, and process notes are HTML-escaped.
809
+ - `processDescription` and `current_status` are stripped from public
810
+ responses (only safe fields reach the browser).
811
+ - The full session id is shortened to 8 hex chars (`sessionShort`).
812
+
813
+ ### Configuration
814
+
815
+ | Key | Default | Purpose |
816
+ |---|---|---|
817
+ | `royAgentCliPath` | auto-detect | Absolute path to the `roy-agent` executable. |
818
+ | `operationsCacheTtlMs` | 5000 | TTL for parsed envelopes. |
819
+ | `maxOperations` | 200 | Hard cap on operations per task. |
820
+ | `maxDescriptionChars` | 2000 | Per-description char cap. |
821
+ | `cliTimeoutMs` | 5000 | Wall-clock timeout for the CLI spawn. |
822
+ | `cliMaxBytes` | 1048576 | Maximum stdout bytes. |
823
+
824
+ ### CLI path resolution
825
+
826
+ The plugin auto-detects the `roy-agent` CLI from these locations
827
+ (first match wins):
828
+
829
+ 1. `cfg.royAgentCliPath` (explicit override).
830
+ 2. `$ROY_AGENT_CLI` environment variable.
831
+ 3. `<cwd>/../roy-agent/packages/cli/dist/bin/roy-agent.js`
832
+ (sibling-repo layout — the typical dev setup).
833
+ 4. The monorepo layout
834
+ (`<pluginDist>/../../../../packages/cli/dist/bin/roy-agent.js`).
835
+ 5. `roy-agent` on PATH (fallback).
836
+
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @fileoverview CLI adapter for `roy-agent tasks get <id> --operations --json`.
3
+ *
4
+ * Design goals:
5
+ * - Spawn the host CLI as a discrete arg array — never via shell string.
6
+ * - Enforce a wall-clock timeout and a maximum stdout size.
7
+ * - Locate the leading `{` in stdout to skip INFO/log lines the real
8
+ * CLI emits before its JSON envelope.
9
+ * - Validate the parsed envelope against a stable schema.
10
+ * - Sort operations ascending by timestamp and assign `sequence` numbers.
11
+ * - Truncate over-long descriptions defensively.
12
+ * - Surface typed errors so callers can map them to HTTP status codes.
13
+ *
14
+ * The adapter is intentionally narrow: it knows nothing about HTTP, the
15
+ * collector, or the frontend. The `runner` is injectable so tests never
16
+ * need a real subprocess.
17
+ */
18
+ import type { TaskShowConfig } from "./types.js";
19
+ export type CanonicalMilestoneType = "create" | "progress" | "milestone" | "problem" | "solution" | "decision" | "review" | "completed" | "unknown";
20
+ /** Subset of the CLI's `task` payload that we surface to the UI. */
21
+ export interface TaskMetadata {
22
+ id: number;
23
+ title: string;
24
+ status: string;
25
+ priority: string;
26
+ type: string;
27
+ progress?: number;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ tags: string[];
31
+ projectPath?: string;
32
+ }
33
+ /** One row in the operations pipeline. */
34
+ export interface OperationRecord {
35
+ id: number;
36
+ /** 1-based sequence number, ascending by timestamp. */
37
+ sequence: number;
38
+ /** Canonical milestone type (or 'unknown' if the CLI emits an unknown value). */
39
+ milestoneType: CanonicalMilestoneType;
40
+ title: string;
41
+ description: string;
42
+ processDescription: string;
43
+ timestamp: string;
44
+ /** Truncated session id for display; the full id is never exposed. */
45
+ sessionShort: string;
46
+ }
47
+ export interface TaskOperationsEnvelope {
48
+ task: TaskMetadata;
49
+ operations: OperationRecord[];
50
+ }
51
+ export interface CachedEnvelope {
52
+ task: TaskMetadata;
53
+ operations: OperationRecord[];
54
+ /** When the source data was last fetched (ISO 8601). */
55
+ fetchedAt: string;
56
+ /** True if the cache entry is past TTL but still served. */
57
+ stale: boolean;
58
+ }
59
+ export interface AdapterRunnerResult {
60
+ stdout: string;
61
+ stderr: string;
62
+ exitCode: number;
63
+ }
64
+ export type AdapterRunner = (args: string[]) => Promise<AdapterRunnerResult>;
65
+ export interface AdapterOptions {
66
+ /** Absolute path to the `roy-agent` executable. */
67
+ cliPath: string;
68
+ /** Mockable subprocess runner (defaults to `defaultRunner`). */
69
+ runner?: AdapterRunner;
70
+ /** Wall-clock timeout (default 5000 ms). */
71
+ timeoutMs?: number;
72
+ /** Maximum stdout bytes to keep (default 1 MiB). */
73
+ maxBytes?: number;
74
+ /** TaskShowConfig for maxOperations / maxDescriptionChars. */
75
+ cfg: TaskShowConfig;
76
+ }
77
+ /** Source interface used by OperationsCache and the HTTP layer. */
78
+ export interface TaskOperationsSource {
79
+ getTaskOperations(taskId: number): Promise<TaskOperationsEnvelope>;
80
+ }
81
+ export declare class AdapterError extends Error {
82
+ readonly code: string;
83
+ readonly cause?: unknown;
84
+ readonly exitCode?: number;
85
+ constructor(message: string, opts?: {
86
+ code?: string;
87
+ exitCode?: number;
88
+ cause?: unknown;
89
+ });
90
+ }
91
+ export declare class TaskNotFoundError extends AdapterError {
92
+ readonly taskId: number;
93
+ constructor(message: string, taskId: number);
94
+ }
95
+ export declare class ParseError extends AdapterError {
96
+ constructor(message: string, cause?: unknown);
97
+ }
98
+ export declare class TimeoutError extends AdapterError {
99
+ constructor(message: string, cause?: unknown);
100
+ }
101
+ export declare class SchemaError extends AdapterError {
102
+ constructor(message: string);
103
+ }
104
+ export declare function assertValidTaskId(id: unknown): asserts id is number;
105
+ /**
106
+ * Default subprocess runner. We deliberately avoid `shell: true` to keep
107
+ * argv as a literal array — no shell metacharacter interpretation. The
108
+ * `AbortController` enforces the wall-clock timeout by killing the child.
109
+ */
110
+ export declare const defaultRunner: AdapterRunner;
111
+ /** Build the argv array. Pure function — easy to unit-test. */
112
+ export declare function buildTasksGetArgs(cliPath: string, taskId: number): string[];
113
+ /**
114
+ * Locate the first top-level `{` in stdout and return everything from
115
+ * there to the end. Tolerant of:
116
+ * - INFO / log lines printed before the JSON envelope
117
+ * - mixed CRLF / LF
118
+ * - `✗ Task not found: N` lines (handled separately by exit code)
119
+ */
120
+ export declare function extractJsonEnvelope(stdout: string): string;
121
+ /**
122
+ * Map any string to a CanonicalMilestoneType. Unknown values fall back
123
+ * to 'unknown' (with the original string preserved under `title` if we
124
+ * ever want to display it).
125
+ */
126
+ export declare function canonicalMilestoneType(raw: unknown): CanonicalMilestoneType;
127
+ /** Shorten a session id like `session_392d70e0-07bc-4a41-8669-ece72f325aad`
128
+ * to `s_392d70e0` (12 chars max, never leaks the full UUID). */
129
+ export declare function shortSessionId(raw: unknown): string;
130
+ /**
131
+ * Run `roy-agent tasks get <id> --operations --json` and return a parsed
132
+ * envelope. Errors are typed so the HTTP layer can map them to status codes.
133
+ *
134
+ * Pass `runner: defaultRunner` in production. Tests pass a `fixedRunner`.
135
+ */
136
+ export declare function runTasksGetOperations(taskId: number, options: AdapterOptions): Promise<TaskOperationsEnvelope>;
137
+ /**
138
+ * Convenience wrapper used by OperationsCache: a `TaskOperationsSource`
139
+ * whose `getTaskOperations(taskId)` returns the parsed envelope or throws.
140
+ */
141
+ export declare function makeTaskOperationsSource(opts: AdapterOptions): TaskOperationsSource;
142
+ //# sourceMappingURL=cli-tasks-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-tasks-adapter.d.ts","sourceRoot":"","sources":["../src/cli-tasks-adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,379 @@
1
+ /**
2
+ * @fileoverview CLI adapter for `roy-agent tasks get <id> --operations --json`.
3
+ *
4
+ * Design goals:
5
+ * - Spawn the host CLI as a discrete arg array — never via shell string.
6
+ * - Enforce a wall-clock timeout and a maximum stdout size.
7
+ * - Locate the leading `{` in stdout to skip INFO/log lines the real
8
+ * CLI emits before its JSON envelope.
9
+ * - Validate the parsed envelope against a stable schema.
10
+ * - Sort operations ascending by timestamp and assign `sequence` numbers.
11
+ * - Truncate over-long descriptions defensively.
12
+ * - Surface typed errors so callers can map them to HTTP status codes.
13
+ *
14
+ * The adapter is intentionally narrow: it knows nothing about HTTP, the
15
+ * collector, or the frontend. The `runner` is injectable so tests never
16
+ * need a real subprocess.
17
+ */
18
+ // ---------------------------------------------------------------------------
19
+ // Error classes
20
+ // ---------------------------------------------------------------------------
21
+ export class AdapterError extends Error {
22
+ code;
23
+ cause;
24
+ exitCode;
25
+ constructor(message, opts = {}) {
26
+ super(message);
27
+ this.name = "AdapterError";
28
+ this.code = opts.code ?? "adapter_error";
29
+ if (opts.exitCode !== undefined)
30
+ this.exitCode = opts.exitCode;
31
+ if (opts.cause !== undefined)
32
+ this.cause = opts.cause;
33
+ }
34
+ }
35
+ export class TaskNotFoundError extends AdapterError {
36
+ taskId;
37
+ constructor(message, taskId) {
38
+ super(message, { code: "not_found" });
39
+ this.name = "TaskNotFoundError";
40
+ this.taskId = taskId;
41
+ }
42
+ }
43
+ export class ParseError extends AdapterError {
44
+ constructor(message, cause) {
45
+ super(message, { code: "parse_error", cause });
46
+ this.name = "ParseError";
47
+ }
48
+ }
49
+ export class TimeoutError extends AdapterError {
50
+ constructor(message, cause) {
51
+ super(message, { code: "timeout", cause });
52
+ this.name = "TimeoutError";
53
+ }
54
+ }
55
+ export class SchemaError extends AdapterError {
56
+ constructor(message) {
57
+ super(message, { code: "schema_error" });
58
+ this.name = "SchemaError";
59
+ }
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Task id validator
63
+ // ---------------------------------------------------------------------------
64
+ export function assertValidTaskId(id) {
65
+ if (typeof id !== "number" || !Number.isFinite(id)) {
66
+ throw new AdapterError(`task id must be a finite number (got ${String(id)})`, { code: "bad_id" });
67
+ }
68
+ if (!Number.isInteger(id)) {
69
+ throw new AdapterError(`task id must be an integer (got ${id})`, { code: "bad_id" });
70
+ }
71
+ if (id <= 0) {
72
+ throw new AdapterError(`task id must be positive (got ${id})`, { code: "bad_id" });
73
+ }
74
+ if (id > Number.MAX_SAFE_INTEGER) {
75
+ throw new AdapterError(`task id out of safe range (got ${id})`, { code: "bad_id" });
76
+ }
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Default runner — spawns the CLI as discrete args with timeout & byte cap
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * Default subprocess runner. We deliberately avoid `shell: true` to keep
83
+ * argv as a literal array — no shell metacharacter interpretation. The
84
+ * `AbortController` enforces the wall-clock timeout by killing the child.
85
+ */
86
+ export const defaultRunner = async (args) => {
87
+ // Lazy import so this file stays test-friendly when the runner is mocked.
88
+ const { spawn } = await import("node:child_process");
89
+ const executable = args[0];
90
+ const argv = args.slice(1);
91
+ return new Promise((resolve, reject) => {
92
+ let settled = false;
93
+ const chunks = [];
94
+ const stderrChunks = [];
95
+ let totalBytes = 0;
96
+ const MAX_BYTES_DEFAULT = 1024 * 1024; // 1 MiB; the real CLI is well under this.
97
+ let truncated = false;
98
+ const child = spawn(executable, argv, {
99
+ stdio: ["ignore", "pipe", "pipe"],
100
+ env: process.env,
101
+ });
102
+ const timer = setTimeout(() => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ try {
107
+ child.kill("SIGKILL");
108
+ }
109
+ catch {
110
+ /* ignore */
111
+ }
112
+ const err = new Error("CLI spawn timed out");
113
+ err.name = "AbortError";
114
+ reject(err);
115
+ }, 30_000); // default; honored by callers who pass timeoutMs via wrapper
116
+ child.stdout.on("data", (buf) => {
117
+ totalBytes += buf.length;
118
+ if (totalBytes > MAX_BYTES_DEFAULT) {
119
+ truncated = true;
120
+ try {
121
+ child.kill("SIGKILL");
122
+ }
123
+ catch {
124
+ /* ignore */
125
+ }
126
+ if (!settled) {
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ resolve({
130
+ stdout: Buffer.concat(chunks).toString("utf8"),
131
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
132
+ exitCode: 0,
133
+ });
134
+ }
135
+ return;
136
+ }
137
+ chunks.push(buf);
138
+ });
139
+ // The real CLI emits "✗ Task not found: <id>" to stderr. We keep
140
+ // it so the adapter can detect not-found and map it to 404.
141
+ child.stderr.on("data", (buf) => {
142
+ stderrChunks.push(buf);
143
+ });
144
+ child.on("error", (err) => {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ reject(err);
150
+ });
151
+ child.on("close", (code) => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ clearTimeout(timer);
156
+ resolve({
157
+ stdout: Buffer.concat(chunks).toString("utf8"),
158
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
159
+ exitCode: code ?? 0,
160
+ });
161
+ });
162
+ });
163
+ };
164
+ /** Build the argv array. Pure function — easy to unit-test. */
165
+ export function buildTasksGetArgs(cliPath, taskId) {
166
+ // The CLI is bundled for Node — its dist has a `#!/usr/bin/env node`
167
+ // shebang. We invoke it through whatever runtime is available; on
168
+ // dev machines that is usually `bun` (TS loader), but `bun` has known
169
+ // TS decorator bugs that crash the CLI. Prefer `node` when available
170
+ // and fall back to `bun`. If neither is on PATH we still hand back
171
+ // a best-effort argv that the OS spawn will resolve.
172
+ const lower = cliPath.toLowerCase();
173
+ const looksLikeScript = lower.endsWith(".js") || lower.endsWith(".ts") || lower.endsWith(".mjs") || lower.endsWith(".cjs");
174
+ if (looksLikeScript) {
175
+ const runtime = pickScriptRuntime();
176
+ return [runtime, cliPath, "tasks", "get", String(taskId), "--operations", "--json"];
177
+ }
178
+ return [cliPath, "tasks", "get", String(taskId), "--operations", "--json"];
179
+ }
180
+ /**
181
+ * Pick the best script runtime. Cached on first call.
182
+ * - `node` is preferred (matches the CLI's shebang).
183
+ * - `bun` is the fallback when running under bun's test runner.
184
+ */
185
+ let cachedRuntime = null;
186
+ function pickScriptRuntime() {
187
+ if (cachedRuntime)
188
+ return cachedRuntime;
189
+ // Prefer an explicit env override
190
+ if (process.env?.TASK_SHOW_CLI_RUNTIME) {
191
+ cachedRuntime = process.env.TASK_SHOW_CLI_RUNTIME;
192
+ return cachedRuntime;
193
+ }
194
+ // Cheap heuristic: Node is preferred.
195
+ cachedRuntime = "node";
196
+ return cachedRuntime;
197
+ }
198
+ // ---------------------------------------------------------------------------
199
+ // stdout → JSON envelope
200
+ // ---------------------------------------------------------------------------
201
+ /**
202
+ * Locate the first top-level `{` in stdout and return everything from
203
+ * there to the end. Tolerant of:
204
+ * - INFO / log lines printed before the JSON envelope
205
+ * - mixed CRLF / LF
206
+ * - `✗ Task not found: N` lines (handled separately by exit code)
207
+ */
208
+ export function extractJsonEnvelope(stdout) {
209
+ const start = stdout.indexOf("{");
210
+ if (start < 0) {
211
+ throw new ParseError("stdout contains no JSON object");
212
+ }
213
+ return stdout.slice(start);
214
+ }
215
+ /**
216
+ * Map any string to a CanonicalMilestoneType. Unknown values fall back
217
+ * to 'unknown' (with the original string preserved under `title` if we
218
+ * ever want to display it).
219
+ */
220
+ export function canonicalMilestoneType(raw) {
221
+ const known = [
222
+ "create",
223
+ "progress",
224
+ "milestone",
225
+ "problem",
226
+ "solution",
227
+ "decision",
228
+ "review",
229
+ "completed",
230
+ ];
231
+ if (typeof raw === "string" && known.includes(raw)) {
232
+ return raw;
233
+ }
234
+ return "unknown";
235
+ }
236
+ /** Truncate a string defensively. */
237
+ function clip(s, max) {
238
+ if (s.length <= max)
239
+ return s;
240
+ return s.slice(0, Math.max(0, max - 1)) + "…";
241
+ }
242
+ /** Shorten a session id like `session_392d70e0-07bc-4a41-8669-ece72f325aad`
243
+ * to `s_392d70e0` (12 chars max, never leaks the full UUID). */
244
+ export function shortSessionId(raw) {
245
+ if (typeof raw !== "string" || raw.length === 0)
246
+ return "";
247
+ // strip the well-known prefix if present
248
+ let id = raw;
249
+ if (id.startsWith("session_"))
250
+ id = id.slice("session_".length);
251
+ // take the first 8 hex chars (or all if shorter)
252
+ const clean = id.replace(/[^a-zA-Z0-9]/g, "");
253
+ return "s_" + clean.slice(0, 8);
254
+ }
255
+ // ---------------------------------------------------------------------------
256
+ // Main entry point
257
+ // ---------------------------------------------------------------------------
258
+ /**
259
+ * Run `roy-agent tasks get <id> --operations --json` and return a parsed
260
+ * envelope. Errors are typed so the HTTP layer can map them to status codes.
261
+ *
262
+ * Pass `runner: defaultRunner` in production. Tests pass a `fixedRunner`.
263
+ */
264
+ export async function runTasksGetOperations(taskId, options) {
265
+ assertValidTaskId(taskId);
266
+ const runner = options.runner ?? defaultRunner;
267
+ const args = buildTasksGetArgs(options.cliPath, taskId);
268
+ // Apply the caller's timeout by wrapping the runner.
269
+ const timeoutMs = options.timeoutMs ?? 5000;
270
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
271
+ let raw;
272
+ try {
273
+ raw = await withTimeout(runner(args), timeoutMs);
274
+ }
275
+ catch (err) {
276
+ if (err?.name === "AbortError" || /timeout|abort/i.test(String(err?.message ?? ""))) {
277
+ throw new TimeoutError(`CLI timeout after ${timeoutMs}ms`, err);
278
+ }
279
+ if (err instanceof AdapterError)
280
+ throw err;
281
+ throw new AdapterError(`CLI runner failed: ${err?.message ?? String(err)}`, { cause: err });
282
+ }
283
+ if (raw.stdout.length > maxBytes) {
284
+ throw new AdapterError(`CLI stdout exceeded maxBytes=${maxBytes} (got ${raw.stdout.length})`, { code: "oversize" });
285
+ }
286
+ // Order matters: first try to parse the envelope; only treat as "not
287
+ // found" when stdout OR stderr literally says so AND we cannot extract
288
+ // a valid JSON object. Many CLIs print migration / OTel logs that
289
+ // include the phrase "not found" in unrelated contexts.
290
+ let parsed;
291
+ try {
292
+ const jsonText = extractJsonEnvelope(raw.stdout);
293
+ parsed = JSON.parse(jsonText);
294
+ }
295
+ catch (err) {
296
+ if (/task not found/i.test(raw.stdout) || /task not found/i.test(raw.stderr ?? "")) {
297
+ throw new TaskNotFoundError(`Task not found: ${taskId}`, taskId);
298
+ }
299
+ if (raw.exitCode !== 0) {
300
+ throw new AdapterError(`CLI exited with code ${raw.exitCode} and no parseable JSON envelope`, { code: "cli_failed", exitCode: raw.exitCode });
301
+ }
302
+ if (err instanceof ParseError || err instanceof SchemaError)
303
+ throw err;
304
+ throw new ParseError("Failed to parse CLI JSON envelope", err);
305
+ }
306
+ if (raw.exitCode !== 0 && !parsed) {
307
+ throw new AdapterError(`CLI exited with code ${raw.exitCode}`, { code: "cli_failed", exitCode: raw.exitCode });
308
+ }
309
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
310
+ throw new SchemaError("envelope root is not an object");
311
+ }
312
+ if (!parsed.task || typeof parsed.task !== "object") {
313
+ throw new SchemaError("envelope missing `task` object");
314
+ }
315
+ if (!Array.isArray(parsed.operations)) {
316
+ throw new SchemaError("envelope missing `operations` array");
317
+ }
318
+ if (parsed.task.id !== taskId) {
319
+ throw new SchemaError(`envelope task.id=${parsed.task.id} does not match requested ${taskId}`);
320
+ }
321
+ // Normalize into our shape.
322
+ const maxOps = options.cfg.maxOperations ?? 200;
323
+ const maxDesc = options.cfg.maxDescriptionChars ?? 2000;
324
+ const normalized = parsed.operations
325
+ .map((op) => ({
326
+ id: Number(op?.id ?? 0),
327
+ sequence: 0, // assigned after sort
328
+ milestoneType: canonicalMilestoneType(op?.milestoneType ?? op?.actionType),
329
+ title: String(op?.milestoneTitle ?? op?.actionTitle ?? ""),
330
+ description: clip(String(op?.milestoneDescription ?? op?.actionDescription ?? ""), maxDesc),
331
+ processDescription: clip(String(op?.processDescription ?? ""), maxDesc),
332
+ timestamp: String(op?.timestamp ?? ""),
333
+ sessionShort: shortSessionId(op?.sessionId),
334
+ }))
335
+ .filter((o) => Number.isFinite(o.id) && o.id > 0)
336
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp))
337
+ .slice(-maxOps)
338
+ .map((o, idx) => ({ ...o, sequence: idx + 1 }));
339
+ const task = {
340
+ id: Number(parsed.task.id),
341
+ title: String(parsed.task.title ?? ""),
342
+ status: String(parsed.task.status ?? "unknown"),
343
+ priority: String(parsed.task.priority ?? "medium"),
344
+ type: String(parsed.task.type ?? "normal"),
345
+ progress: typeof parsed.task.progress === "number" ? parsed.task.progress : undefined,
346
+ createdAt: String(parsed.task.createdAt ?? ""),
347
+ updatedAt: String(parsed.task.updatedAt ?? ""),
348
+ tags: Array.isArray(parsed.task.tags) ? parsed.task.tags.map((t) => String(t)) : [],
349
+ projectPath: typeof parsed.task.project_path === "string" ? parsed.task.project_path : undefined,
350
+ };
351
+ return { task, operations: normalized };
352
+ }
353
+ /** Wrap a promise with a wall-clock timeout. */
354
+ function withTimeout(p, ms) {
355
+ let timer;
356
+ const timeout = new Promise((_, reject) => {
357
+ timer = setTimeout(() => {
358
+ const err = new Error(`timed out after ${ms}ms`);
359
+ err.name = "AbortError";
360
+ reject(err);
361
+ }, ms);
362
+ });
363
+ return Promise.race([p, timeout]).finally(() => {
364
+ if (timer)
365
+ clearTimeout(timer);
366
+ });
367
+ }
368
+ /**
369
+ * Convenience wrapper used by OperationsCache: a `TaskOperationsSource`
370
+ * whose `getTaskOperations(taskId)` returns the parsed envelope or throws.
371
+ */
372
+ export function makeTaskOperationsSource(opts) {
373
+ return {
374
+ async getTaskOperations(taskId) {
375
+ return runTasksGetOperations(taskId, opts);
376
+ },
377
+ };
378
+ }
379
+ //# sourceMappingURL=cli-tasks-adapter.js.map