@polderlabs/bizar 5.4.0 → 5.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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -43,17 +43,60 @@ import os from "node:os";
43
43
 
44
44
  // --- Public types (spec §3.2) ---------------------------------------------
45
45
 
46
+ /**
47
+ * v5.x — Per-tool-call history entry. One row per tool invocation.
48
+ * `result` is the truncated text returned by the tool; `error` is the
49
+ * error message if the tool threw.
50
+ *
51
+ * Persistence: stored as `toolCalls` on `BackgroundState`, capped at
52
+ * {@link MAX_TOOL_CALL_HISTORY} entries to keep the JSON state file
53
+ * small. The `args` and `result` fields are truncated to
54
+ * {@link MAX_TOOL_ARG_CHARS} characters each.
55
+ */
56
+ export interface ToolCallEntry {
57
+ /** Plugin-generated tool call id. */
58
+ id: string;
59
+ /** Tool name (e.g. "bash", "edit", "read"). */
60
+ name: string;
61
+ /** JSON-serialized args (truncated). */
62
+ args: string;
63
+ /** "running" while the tool is in-flight; "ok" or "error" after. */
64
+ status: "running" | "ok" | "error";
65
+ /** Epoch ms when the tool call started. */
66
+ startedAt: number;
67
+ /** Epoch ms when the tool call completed (omitted while running). */
68
+ endedAt?: number;
69
+ /** Result preview (truncated). */
70
+ result?: string;
71
+ /** Error message (if status === "error"). */
72
+ error?: string;
73
+ }
74
+
75
+ /** v5.x — Cap on how many tool calls we remember per instance. */
76
+ export const MAX_TOOL_CALL_HISTORY = 100;
77
+ /** v5.x — Cap on per-tool args/result length (chars). */
78
+ export const MAX_TOOL_ARG_CHARS = 1_000;
79
+
46
80
  /**
47
81
  * Background instance status. Maps opencode events and lifecycle transitions
48
82
  * to a small, stable set of terminal and in-flight states.
83
+ *
84
+ * v5.x — `paused` added for pause/resume support. `steered` added for
85
+ * mid-flight redirect (kill + restart with new prompt). Both are
86
+ * informational and don't change the wire-state contract: `paused` is
87
+ * a non-terminal in-flight status (the underlying subprocess is alive
88
+ * but suspended), and `steered` is treated as terminal for cleanup
89
+ * purposes because the original subprocess has exited.
49
90
  */
50
91
  export type BackgroundStatus =
51
92
  | "pending"
52
93
  | "running"
94
+ | "paused"
53
95
  | "done"
54
96
  | "failed"
55
97
  | "killed"
56
- | "timed_out";
98
+ | "timed_out"
99
+ | "steered";
57
100
 
58
101
  /**
59
102
  * Per-instance state for a background agent.
@@ -78,10 +121,11 @@ export type BackgroundStatus =
78
121
  * - `parentAgent` — who spawned it (always "odin" in v0.4 per §6.3).
79
122
  * - `parentInstanceId` — for nested spawns (reserved, not in v0.4).
80
123
  * - `logPath` — path to `~/.cache/bizar/logs/<sessionId>.log`.
81
- * - `toolCallCount` — updated via `EventMessagePartUpdated` events.
82
- * - `loopGuardTool` set when threshold-12 throw is captured. Used at
83
- * `bizar_collect` to prepend the marker.
84
- * - `timeoutMs` collect-time timeout requested by caller.
124
+ * - `toolCallCount` — updated via `EventMessagePartUpdated` events.
125
+ * For tool call history with names/args/results, see `toolCalls`.
126
+ * - `loopGuardTool` set when threshold-12 throw is captured. Used at
127
+ * `bizar_collect` to prepend the marker.
128
+ * - `timeoutMs` — collect-time timeout requested by caller.
85
129
  * - `lastEventAt` — epoch ms when the most recent SSE event arrived for
86
130
  * this instance. Set on every event the manager observes (tool/text
87
131
  * part updates, session.idle, session.error, session.created/updated/
@@ -151,6 +195,28 @@ export interface BackgroundState {
151
195
  timeoutMs: number;
152
196
  toolCallCount: number;
153
197
  loopGuardTool?: string;
198
+ /**
199
+ * v5.x — Per-tool-call history. Records each tool invocation with its
200
+ * args, status, timing, and (when available) the tool result or
201
+ * error. Capped at the last 100 entries per instance (see
202
+ * `MAX_TOOL_CALL_HISTORY`). Exposed via `InstanceView.toolCalls` so
203
+ * the dashboard can render a tool-call list.
204
+ */
205
+ toolCalls?: ToolCallEntry[];
206
+ /**
207
+ * v5.x — Progress reporting. Set by `bizar_report_progress` from the
208
+ * running agent. Range 0..100; -1 means "indeterminate". The
209
+ * dashboard renders this as a progress bar.
210
+ */
211
+ progress?: number;
212
+ /** v5.x — Free-form progress message from the agent. */
213
+ progressMessage?: string;
214
+ /** v5.x — When the progress was last updated. */
215
+ progressAt?: number;
216
+ /** v5.x — Pause timestamp (when the subprocess was SIGSTOPed). */
217
+ pausedAt?: number;
218
+ /** v5.x — Optional list of tags supplied at spawn time. */
219
+ tags?: string[];
154
220
  // v0.3.0 — stall and thinking-loop protection. These are typed as
155
221
  // optional in the schema because (a) the field can be absent in older
156
222
  // files on disk, and (b) the `InstanceManager.add()` input (AddDraft)
@@ -229,12 +295,18 @@ export const EMPTY_BACKGROUND_STATE: Omit<
229
295
 
230
296
  /**
231
297
  * Terminal states per spec §1.6 / §4.4. Used by collect/await logic.
298
+ *
299
+ * v5.x — `steered` is treated as terminal because the original
300
+ * subprocess has exited; a new instance is created to carry the
301
+ * steered work. Collect on a steered instance returns whatever text
302
+ * the subprocess emitted before the kill.
232
303
  */
233
304
  export const TERMINAL_STATUSES: ReadonlySet<BackgroundStatus> = new Set<BackgroundStatus>([
234
305
  "done",
235
306
  "failed",
236
307
  "killed",
237
308
  "timed_out",
309
+ "steered",
238
310
  ]);
239
311
 
240
312
  // --- Logger interface -----------------------------------------------------
@@ -391,6 +463,13 @@ function readState(
391
463
  if (typeof parsed.maxRestarts !== "number") {
392
464
  parsed.maxRestarts = 3;
393
465
  }
466
+ // v5.x — backfill tool call history and progress for old files.
467
+ if (!Array.isArray(parsed.toolCalls)) {
468
+ parsed.toolCalls = [];
469
+ }
470
+ if (typeof parsed.progress !== "number") {
471
+ parsed.progress = 0;
472
+ }
394
473
  return parsed;
395
474
  } catch (err: unknown) {
396
475
  logger.log({
@@ -61,13 +61,24 @@
61
61
  * map is never left in a half-state.
62
62
  */
63
63
 
64
- import type { BackgroundState, BackgroundStateStore, Logger } from "./background-state.js";
65
- import { TERMINAL_STATUSES } from "./background-state.js";
64
+ import type {
65
+ BackgroundState,
66
+ BackgroundStateStore,
67
+ Logger,
68
+ ToolCallEntry,
69
+ } from "./background-state.js";
70
+ import {
71
+ MAX_TOOL_CALL_HISTORY,
72
+ MAX_TOOL_ARG_CHARS,
73
+ TERMINAL_STATUSES,
74
+ } from "./background-state.js";
66
75
  import type { HttpClient } from "./http-client.js";
67
76
  import type { EventStream, StreamEvent, SessionEventHandler } from "./event-stream.js";
68
77
  import type { ServeLifecycle } from "./serve.js";
69
78
  import { researchInterventionPrompt } from "./research-prompt.js";
70
79
 
80
+ import * as opencodeRunner from "./opencode-runner.js";
81
+
71
82
  // --- Public surface -------------------------------------------------------
72
83
 
73
84
  /** A snapshot of an instance for the `bizar_status` tool. */
@@ -89,6 +100,21 @@ export interface InstanceView {
89
100
  interventionCount?: number;
90
101
  interventionAt?: number;
91
102
  interventionReason?: string;
103
+ // v5.x — extended surface for dashboard integration
104
+ /** Tool call history (cap 100). Each entry has name, args, status, timing. */
105
+ toolCalls?: ToolCallEntry[];
106
+ /** Progress percentage (0..100; -1 = indeterminate). */
107
+ progress?: number;
108
+ /** Progress message (free-form). */
109
+ progressMessage?: string;
110
+ /** PID of the opencode run subprocess, if known. */
111
+ processId?: number;
112
+ /** Current runner state (one of "starting"|"running"|"done"|"failed"|"killed"). */
113
+ runnerState?: string;
114
+ /** Tags from spawn (free-form). */
115
+ tags?: string[];
116
+ /** When the instance was paused (epoch ms), if paused. */
117
+ pausedAt?: number;
92
118
  }
93
119
 
94
120
  /** The return shape of `bizar_collect`. */
@@ -706,9 +732,22 @@ export class InstanceManager {
706
732
 
707
733
  /**
708
734
  * Scan the bg directory, load every instance, and rebuild the in-memory
709
- * map. Any `running` or `pending` instance is marked `failed` because
710
- * the serve child is new and the opencode sessions are gone.
711
- * Historical records (done, failed, killed, timed_out) are preserved.
735
+ * map. Any `running` or `pending` instance is checked for liveness:
736
+ *
737
+ * - If the instance has a `processId` AND the opencode run subprocess
738
+ * is still alive (per `opencodeRunner.isAlive()`), we RE-ADOPT it:
739
+ * status flips back to `running`, `runnerState: "adopted"`, and the
740
+ * the manager continues to monitor via the existing
741
+ * `onExit` callback registered at spawn time.
742
+ * - If the subprocess is gone (or there was no PID on file), we mark
743
+ * the instance `failed` with reason "subprocess died during restart".
744
+ *
745
+ * Historical terminal records (done, failed, killed, timed_out, steered)
746
+ * are preserved as-is.
747
+ *
748
+ * Best-effort: liveness check uses `process.kill(pid, 0)` which is fast
749
+ * (no signal is sent) and safe (returns false on EPERM / ESRCH without
750
+ * raising to us).
712
751
  */
713
752
  async rebuildInMemoryMap(): Promise<void> {
714
753
  let all: BackgroundState[];
@@ -724,14 +763,27 @@ export class InstanceManager {
724
763
  }
725
764
  let rebuilt = 0;
726
765
  let failed = 0;
766
+ let adopted = 0;
727
767
  for (const inst of all) {
728
768
  this.instances.set(inst.instanceId, inst);
729
769
  rebuilt += 1;
730
770
  if (inst.status === "running" || inst.status === "pending") {
731
- const message =
732
- inst.status === "pending"
733
- ? "plugin restarted while instance was pending"
734
- : "plugin restarted; serve child is new";
771
+ // Try to adopt a still-alive subprocess.
772
+ const pid = inst.processId;
773
+ const alive = typeof pid === "number" && opencodeRunner.isAlive(pid);
774
+ if (alive) {
775
+ await this.update(inst.instanceId, {
776
+ status: "running",
777
+ completedAt: undefined,
778
+ runnerState: "adopted",
779
+ });
780
+ adopted += 1;
781
+ this.logger.info(
782
+ `bizar: adopted still-alive subprocess for instance ${inst.instanceId} (pid=${pid})`,
783
+ );
784
+ continue;
785
+ }
786
+ const message = "subprocess died during restart";
735
787
  await this.update(inst.instanceId, {
736
788
  status: "failed",
737
789
  error: message,
@@ -742,7 +794,7 @@ export class InstanceManager {
742
794
  }
743
795
  if (rebuilt > 0) {
744
796
  this.logger.info(
745
- `bizar: rebuilt in-memory map (${rebuilt} instances, ${failed} marked failed)`,
797
+ `bizar: rebuilt in-memory map (${rebuilt} instances, ${adopted} adopted, ${failed} marked failed)`,
746
798
  );
747
799
  }
748
800
  }
@@ -991,6 +1043,192 @@ export class InstanceManager {
991
1043
  await this._maybeAutoRestart(instanceId);
992
1044
  }
993
1045
 
1046
+ // --- v5.x — pause / resume / progress / tool-call instrumentation -------
1047
+
1048
+ /**
1049
+ * Pause a running instance. Sends SIGSTOP to the opencode run subprocess
1050
+ * (POSIX only — returns `{ ok: false, error: "..." }` on Windows and
1051
+ * for processes we can't signal). On success, the in-memory status
1052
+ * flips to `"paused"` and `pausedAt` is stamped.
1053
+ *
1054
+ * Already-terminal instances return `{ ok: false, error: "already_terminal" }`.
1055
+ * Already-paused instances return `{ ok: true }` (no-op).
1056
+ */
1057
+ async pause(instanceId: string): Promise<{ ok: boolean; error?: string }> {
1058
+ const inst = this.instances.get(instanceId);
1059
+ if (!inst) return { ok: false, error: "instance_not_found" };
1060
+ if (TERMINAL_STATUSES.has(inst.status)) {
1061
+ return { ok: false, error: "already_terminal" };
1062
+ }
1063
+ if (inst.status === "paused") return { ok: true };
1064
+ if (inst.processId === undefined) {
1065
+ // No PID on file — for v0.8.0 the runner is the only owner of the
1066
+ // subprocess. Try the runner's registry as a fallback before giving up.
1067
+ const runnerStatus = opencodeRunner.getStatus(NaN as unknown as number);
1068
+ if (runnerStatus === null) {
1069
+ return { ok: false, error: "no_subprocess" };
1070
+ }
1071
+ }
1072
+ const pid = inst.processId;
1073
+ if (pid === undefined) {
1074
+ return { ok: false, error: "no_subprocess" };
1075
+ }
1076
+ const res = opencodeRunner.pauseAgent(pid);
1077
+ if (!res.ok) return { ok: false, error: res.error || "pause_failed" };
1078
+ await this.update(instanceId, {
1079
+ status: "paused",
1080
+ pausedAt: Date.now(),
1081
+ });
1082
+ this.logger.info(
1083
+ `bizar: paused background instance ${instanceId} (pid=${pid})`,
1084
+ );
1085
+ return { ok: true };
1086
+ }
1087
+
1088
+ /**
1089
+ * Resume a paused instance. Sends SIGCONT to the subprocess and flips
1090
+ * status back to `"running"`. Idempotent: resuming a non-paused
1091
+ * instance is a no-op that returns `{ ok: true }`.
1092
+ */
1093
+ async resume(instanceId: string): Promise<{ ok: boolean; error?: string }> {
1094
+ const inst = this.instances.get(instanceId);
1095
+ if (!inst) return { ok: false, error: "instance_not_found" };
1096
+ if (inst.status !== "paused") return { ok: true };
1097
+ if (inst.processId === undefined) {
1098
+ return { ok: false, error: "no_subprocess" };
1099
+ }
1100
+ const res = opencodeRunner.resumeAgent(inst.processId);
1101
+ if (!res.ok) return { ok: false, error: res.error || "resume_failed" };
1102
+ await this.update(instanceId, {
1103
+ status: "running",
1104
+ pausedAt: undefined,
1105
+ });
1106
+ this.logger.info(
1107
+ `bizar: resumed background instance ${instanceId} (pid=${inst.processId})`,
1108
+ );
1109
+ return { ok: true };
1110
+ }
1111
+
1112
+ /**
1113
+ * Update progress (and optional message) for an instance. Called by the
1114
+ * `bizar_report_progress` tool. Pinned to [0, 100] unless `indeterminate`
1115
+ * is true, in which case we set progress to -1.
1116
+ */
1117
+ async updateProgress(
1118
+ instanceId: string,
1119
+ step: number,
1120
+ total: number,
1121
+ message?: string,
1122
+ indeterminate = false,
1123
+ ): Promise<{ ok: boolean; progress?: number; error?: string }> {
1124
+ const inst = this.instances.get(instanceId);
1125
+ if (!inst) return { ok: false, error: "instance_not_found" };
1126
+ if (TERMINAL_STATUSES.has(inst.status)) {
1127
+ return { ok: false, error: "already_terminal" };
1128
+ }
1129
+ let pct: number;
1130
+ if (indeterminate) {
1131
+ pct = -1;
1132
+ } else {
1133
+ const safeTotal = Math.max(1, Math.floor(total));
1134
+ const safeStep = Math.max(0, Math.floor(step));
1135
+ pct = Math.max(0, Math.min(100, Math.round((safeStep / safeTotal) * 100)));
1136
+ }
1137
+ const patch: Partial<BackgroundState> = {
1138
+ progress: pct,
1139
+ progressAt: Date.now(),
1140
+ };
1141
+ if (message !== undefined) patch.progressMessage = message.slice(0, 500);
1142
+ await this.update(instanceId, patch);
1143
+ return { ok: true, progress: pct };
1144
+ }
1145
+
1146
+ /**
1147
+ * Record a tool-call start. Returns the generated id so callers can
1148
+ * pass it to `completeToolCall()` later. Caps the history at
1149
+ * `MAX_TOOL_CALL_HISTORY` entries.
1150
+ */
1151
+ async recordToolCallStart(
1152
+ instanceId: string,
1153
+ name: string,
1154
+ args: unknown,
1155
+ ): Promise<string> {
1156
+ const id = `tc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1157
+ const inst = this.instances.get(instanceId);
1158
+ if (!inst) return id;
1159
+ const entry: ToolCallEntry = {
1160
+ id,
1161
+ name: String(name ?? "").slice(0, 200),
1162
+ args: truncateForToolHistory(args),
1163
+ status: "running",
1164
+ startedAt: Date.now(),
1165
+ };
1166
+ const existing = (inst.toolCalls ?? []).slice(-MAX_TOOL_CALL_HISTORY + 1);
1167
+ const next = [...existing, entry].slice(-MAX_TOOL_CALL_HISTORY);
1168
+ await this.update(instanceId, { toolCalls: next });
1169
+ return id;
1170
+ }
1171
+
1172
+ /**
1173
+ * Mark a tool-call complete. `result` and `error` are both truncated.
1174
+ * A no-op if no matching entry is found (the cap may have rolled it off).
1175
+ */
1176
+ async completeToolCall(
1177
+ instanceId: string,
1178
+ id: string,
1179
+ result?: string,
1180
+ error?: string,
1181
+ ): Promise<void> {
1182
+ const inst = this.instances.get(instanceId);
1183
+ if (!inst || !Array.isArray(inst.toolCalls)) return;
1184
+ const idx = inst.toolCalls.findIndex((t) => t.id === id);
1185
+ if (idx < 0) return;
1186
+ const cur = inst.toolCalls[idx];
1187
+ if (!cur) return;
1188
+ const next = inst.toolCalls.slice();
1189
+ const updated: ToolCallEntry = {
1190
+ ...cur,
1191
+ status: error ? "error" : "ok",
1192
+ endedAt: Date.now(),
1193
+ };
1194
+ if (error !== undefined) {
1195
+ updated.error = String(error).slice(0, MAX_TOOL_ARG_CHARS);
1196
+ } else if (result !== undefined) {
1197
+ updated.result = String(result).slice(0, MAX_TOOL_ARG_CHARS);
1198
+ }
1199
+ next[idx] = updated;
1200
+ await this.update(instanceId, { toolCalls: next });
1201
+ }
1202
+
1203
+ /**
1204
+ * Public accessor used by the opencode-runner when it wants to surface
1205
+ * the recorded tool-call list (read-only). Returns a copy.
1206
+ */
1207
+ listToolCalls(instanceId: string): ToolCallEntry[] {
1208
+ const inst = this.instances.get(instanceId);
1209
+ if (!inst || !Array.isArray(inst.toolCalls)) return [];
1210
+ return inst.toolCalls.slice();
1211
+ }
1212
+
1213
+ /**
1214
+ * Public: mark an instance as "adopted" — used by `rebuildInMemoryMap`
1215
+ * when a previously running instance's subprocess is still alive. The
1216
+ * status flips back to "running" and the runner is resumed.
1217
+ */
1218
+ async adoptInstance(instanceId: string): Promise<void> {
1219
+ const inst = this.instances.get(instanceId);
1220
+ if (!inst) return;
1221
+ if (!TERMINAL_STATUSES.has(inst.status)) {
1222
+ // Already alive in our map; nothing to adopt.
1223
+ return;
1224
+ }
1225
+ await this.update(instanceId, {
1226
+ status: "running",
1227
+ completedAt: undefined,
1228
+ runnerState: "adopted",
1229
+ });
1230
+ }
1231
+
994
1232
  /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
995
1233
  private async _maybeAutoRestart(instanceId: string): Promise<void> {
996
1234
  const inst = this.instances.get(instanceId);
@@ -1066,6 +1304,59 @@ export class InstanceManager {
1066
1304
  }
1067
1305
  }
1068
1306
 
1307
+ // --- v5.x — Tool call history recording -----------------------------
1308
+ // The tool part re-fires on every state transition (pending ->
1309
+ // running -> completed). We key the history entry by `partID` so
1310
+ // the same call's "running" and "completed" messages converge to
1311
+ // one row. When a part has no entry yet, we record a fresh "running"
1312
+ // entry; when it already exists, we update status/result/error.
1313
+ if (part.type === "tool") {
1314
+ const toolName = readToolName(part);
1315
+ const existing = Array.isArray(inst.toolCalls)
1316
+ ? inst.toolCalls.find((t) => t.id === ev.partID)
1317
+ : undefined;
1318
+ if (existing === undefined) {
1319
+ // Fresh tool call — record start.
1320
+ const tcId = ev.partID;
1321
+ const entry: ToolCallEntry = {
1322
+ id: tcId,
1323
+ name: toolName,
1324
+ args: readToolArgs(part),
1325
+ status: readToolStatus(part),
1326
+ startedAt: Date.now(),
1327
+ };
1328
+ const merged = [...(inst.toolCalls ?? []), entry].slice(
1329
+ -MAX_TOOL_CALL_HISTORY,
1330
+ );
1331
+ await this.update(instanceId, { toolCalls: merged });
1332
+ } else {
1333
+ // Existing entry — update status/result/error.
1334
+ const next = (inst.toolCalls ?? []).slice();
1335
+ const idx = next.findIndex((t) => t.id === ev.partID);
1336
+ if (idx >= 0) {
1337
+ const cur = next[idx];
1338
+ if (cur) {
1339
+ const status = readToolStatus(part);
1340
+ const updated: ToolCallEntry = {
1341
+ ...cur,
1342
+ status,
1343
+ endedAt: status === "ok" || status === "error" ? Date.now() : cur.endedAt,
1344
+ };
1345
+ const errText = readToolError(part);
1346
+ if (errText) updated.error = errText.slice(0, MAX_TOOL_ARG_CHARS);
1347
+ // If the part carries a result string in `state.output` or
1348
+ // a result-shaped field, surface it. The opencode schema
1349
+ // is loose here — we accept either `state.output` or raw
1350
+ // `output`.
1351
+ const output = readToolOutput(part);
1352
+ if (output) updated.result = output.slice(0, MAX_TOOL_ARG_CHARS);
1353
+ next[idx] = updated;
1354
+ await this.update(instanceId, { toolCalls: next });
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+
1069
1360
  // --- Loop-guard threshold-12 detection (spec §4.1) ---
1070
1361
  if (part.type === "tool" && !inst.loopGuardTool) {
1071
1362
  const errorText = readToolError(part);
@@ -1221,6 +1512,16 @@ function toView(inst: BackgroundState): InstanceView {
1221
1512
  if (inst.interventionAt !== undefined) v.interventionAt = inst.interventionAt;
1222
1513
  if (inst.interventionReason !== undefined) v.interventionReason = inst.interventionReason;
1223
1514
  }
1515
+ // v5.x — extended dashboard surface.
1516
+ if (Array.isArray(inst.toolCalls) && inst.toolCalls.length > 0) {
1517
+ v.toolCalls = inst.toolCalls.slice();
1518
+ }
1519
+ if (typeof inst.progress === "number") v.progress = inst.progress;
1520
+ if (inst.progressMessage !== undefined) v.progressMessage = inst.progressMessage;
1521
+ if (inst.processId !== undefined) v.processId = inst.processId;
1522
+ if (inst.runnerState !== undefined) v.runnerState = inst.runnerState;
1523
+ if (Array.isArray(inst.tags) && inst.tags.length > 0) v.tags = inst.tags.slice();
1524
+ if (inst.pausedAt !== undefined) v.pausedAt = inst.pausedAt;
1224
1525
  return v;
1225
1526
  }
1226
1527
 
@@ -1237,6 +1538,66 @@ function readToolError(part: { error?: string; state?: { error?: string } }): st
1237
1538
  return null;
1238
1539
  }
1239
1540
 
1541
+ /**
1542
+ * v5.x — Read the tool name from a tool part. opencode's wire format
1543
+ * is loose: the tool name may live on `part.tool`, `part.name`, or as
1544
+ * the value of the first entries. Returns "unknown" when nothing
1545
+ * matches.
1546
+ */
1547
+ function readToolName(part: { tool?: unknown; name?: unknown; [k: string]: unknown }): string {
1548
+ const candidates = [part.tool, part.name];
1549
+ for (const c of candidates) {
1550
+ if (typeof c === "string" && c.length > 0) return c.slice(0, 200);
1551
+ }
1552
+ return "unknown";
1553
+ }
1554
+
1555
+ /**
1556
+ * v5.x — Read the tool args from a tool part. opencode may carry
1557
+ * `args`, `input`, or a JSON-stringified `arguments`. Returns "" when
1558
+ * nothing useful is found.
1559
+ */
1560
+ function readToolArgs(part: { args?: unknown; input?: unknown; arguments?: unknown; [k: string]: unknown }): string {
1561
+ const candidates = [part.args, part.input, part.arguments];
1562
+ for (const c of candidates) {
1563
+ if (c === undefined || c === null) continue;
1564
+ if (typeof c === "string") return c.slice(0, MAX_TOOL_ARG_CHARS);
1565
+ try {
1566
+ return JSON.stringify(c).slice(0, MAX_TOOL_ARG_CHARS);
1567
+ } catch {
1568
+ // fall through
1569
+ }
1570
+ }
1571
+ return "";
1572
+ }
1573
+
1574
+ /**
1575
+ * v5.x — Map the part's state.status (or top-level status) to our
1576
+ * ToolCallEntry status enum.
1577
+ */
1578
+ function readToolStatus(part: { state?: { status?: unknown }; status?: unknown }): "running" | "ok" | "error" {
1579
+ const s = part.state?.status ?? part.status;
1580
+ if (typeof s !== "string") return "running";
1581
+ const lower = s.toLowerCase();
1582
+ if (lower === "ok" || lower === "success" || lower === "completed" || lower === "done") return "ok";
1583
+ if (lower === "error" || lower === "failed") return "error";
1584
+ return "running";
1585
+ }
1586
+
1587
+ function readToolOutput(part: { state?: { output?: unknown; result?: unknown; status?: unknown; error?: unknown }; output?: unknown; result?: unknown }): string {
1588
+ const candidates = [part.state?.output, part.state?.result, part.output, part.result];
1589
+ for (const c of candidates) {
1590
+ if (c === undefined || c === null) continue;
1591
+ if (typeof c === "string") return c;
1592
+ try {
1593
+ return JSON.stringify(c);
1594
+ } catch {
1595
+ // fall through
1596
+ }
1597
+ }
1598
+ return "";
1599
+ }
1600
+
1240
1601
  async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
1241
1602
  let timer: ReturnType<typeof setTimeout> | null = null;
1242
1603
  const timeout = new Promise<never>((_, reject) => {
@@ -1248,3 +1609,20 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
1248
1609
  if (timer !== null) clearTimeout(timer);
1249
1610
  }
1250
1611
  }
1612
+
1613
+ /**
1614
+ * Serialize an unknown tool-args value into a string suitable for the
1615
+ * `toolCalls` history. JSON-serializes objects, truncates to
1616
+ * {@link MAX_TOOL_ARG_CHARS}. Returns `""` for empty/undefined.
1617
+ */
1618
+ function truncateForToolHistory(value: unknown): string {
1619
+ if (value === undefined || value === null) return "";
1620
+ let s: string;
1621
+ try {
1622
+ s = typeof value === "string" ? value : JSON.stringify(value);
1623
+ } catch {
1624
+ s = String(value);
1625
+ }
1626
+ if (s.length > MAX_TOOL_ARG_CHARS) s = s.slice(0, MAX_TOOL_ARG_CHARS) + "…";
1627
+ return s;
1628
+ }