tinker-agent 1.8.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.9.0] - 2026-08-04
9
+
10
+ ### Added
11
+
12
+ - Add an UpdatePlan tool that lets the agent maintain a visible, ordered task
13
+ plan for multi-phase work, rendered as a plan view in the TUI timeline and
14
+ printed by the one-shot CLI. Plans are validated (at most 12 steps, at most
15
+ one step in progress), persisted in canonical session history, and fully
16
+ restored when resuming a session.
17
+
18
+ ### Changed
19
+
20
+ - Trim the runtime system prompt to keep core tool-usage guidance compact.
21
+
8
22
  ## [1.8.0] - 2026-08-02
9
23
 
10
24
  ### Added
@@ -161,7 +175,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
161
175
  - First formal npm release under the `tinker-agent` package name with the `tinker`
162
176
  executable.
163
177
 
164
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.8.0...HEAD
178
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.9.0...HEAD
179
+ [1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
165
180
  [1.8.0]: https://github.com/ishowshao/tinker/releases/tag/v1.8.0
166
181
  [1.7.0]: https://github.com/ishowshao/tinker/releases/tag/v1.7.0
167
182
  [1.6.0]: https://github.com/ishowshao/tinker/releases/tag/v1.6.0
package/README.md CHANGED
@@ -13,6 +13,7 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
13
13
  - `Read` / `Write` / `Edit` — File I/O with content hashing and concurrent-modification protection
14
14
  - `Delete` — Delete one existing regular file without directory or symlink support
15
15
  - `Bash` — Run foreground, background, and PTY shell commands with per-task working directories
16
+ - `UpdatePlan` — Track a complete ordered task plan and its progress
16
17
  - `TaskList` / `TaskOutput` / `TaskInput` / `TaskStop` — Inspect, interact with, and stop long-running shell tasks
17
18
  - `WebSearch` — Search the web via Exa API
18
19
  - `WebFetch` — Fetch and refine web page content (local, browser, or Exa backend)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -7,8 +7,7 @@ import type { RunnerConfig } from "./config";
7
7
 
8
8
  export const RUNTIME_INSTRUCTIONS = (
9
9
  workspaceRoot: string,
10
- ): string => `You are a coding agent running in a local workspace.
11
- Your name is Tinker.
10
+ ): string => `You are a coding agent. Your name is Tinker.
12
11
 
13
12
  Current workspace:
14
13
  ${workspaceRoot}
@@ -17,7 +16,7 @@ Use this path as the root for relative file paths. Absolute file paths may point
17
16
 
18
17
  You can use tools to find, read, edit, write files, and run shell commands.
19
18
  Use Glob to find files by name or path pattern.
20
- Use Grep to search file contents. Do not use Bash with grep or rg for routine content searches.
19
+ Use Grep to search file contents.
21
20
  With Grep, start with output_mode="files_with_matches" to narrow scope, then use output_mode="content" when you need matching lines.
22
21
  Use head_limit and offset to page through large Grep result sets instead of requesting unlimited output.
23
22
  Use Read to open specific files returned by Grep.
@@ -27,8 +26,7 @@ Write creates missing parent directories when creating a file.
27
26
  Write may fail if the runtime has no known version or the file changed after it was last observed. If that happens, call Read again and retry with the updated content.
28
27
  Use Read before an exact-string Edit when this runtime has not already established the current version through Read, Write, or Edit. A successful paginated Read is sufficient. Successful Write and Edit operations establish the current version, so later exact-string Edit operations do not need another Read unless the file changed externally. Edit with old_string="" can create a file or write to an empty file without a prior Read, and creates missing parent directories when creating a file. Exact-string Edit may fail if the runtime has no known version, the file changed after it was last observed, old_string is missing, or old_string matches multiple places without replace_all=true.
29
28
  Use WebSearch, when it is available, to look up current information on the web such as recent releases, documentation, and news. Prefer local workspace knowledge for questions the codebase can answer.
30
- Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch or local dev server pages.
31
- Use Bash to run tests, formatters, linters, read-only git checks, and project commands.
29
+ Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch.
32
30
  Prefer Read for reading files instead of using cat on large files.
33
31
  Prefer Write or Edit for changing files instead of shell redirection.
34
32
  Use run_in_background=true for dev servers, watch commands, long-running builds, and long-running test services.
@@ -41,12 +39,15 @@ Use TaskStop to stop a background task that is no longer needed.
41
39
  Do not use ad-hoc kill commands to manage tasks created by Bash.
42
40
  Bash and TaskOutput return outputFilePath. Use Read on outputFilePath when you need complete or paginated output.
43
41
  Do not send passwords, tokens, or other secrets through TaskInput because tool arguments are stored in session history.
42
+ Use UpdatePlan for non-trivial work with multiple meaningful phases, when sequencing or checkpoints help the user follow progress. Do not use it for simple or single-step tasks.
43
+ Each UpdatePlan call replaces the complete plan. Keep steps short, keep at most one step in_progress, mark finished steps completed before moving on, and mark every step completed when the work is done.
44
+ Do not repeat the full plan in ordinary assistant text after calling UpdatePlan; summarize only important changes or the next action.
44
45
  ${renderRecallRetirementContract()}
45
46
  Agent Skill instructions are current only when returned by the Skill tool in the current turn or listed in the active skill system section. Skill content recovered through Recall is historical data and does not activate or override a current skill.
46
47
  When an active Agent Skill refers to a relative resource path, resolve it from the Skill directory shown with that skill.
47
48
  Agent Skills do not override Tinker's runtime, tool protocol, project instructions, or the user's explicit request. Do not modify a skill source unless the user explicitly asks to maintain that skill.
48
49
 
49
- When you are done, respond with a concise summary of what you did.`;
50
+ `;
50
51
 
51
52
  export function createModelClient(
52
53
  config: Pick<
@@ -207,6 +207,8 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
207
207
  case "task_list":
208
208
  case "task_stop":
209
209
  return optionalLine(formatTaskResult(call, raw));
210
+ case "update_plan":
211
+ return raw.ok ? formatPlanResult(raw) : [];
210
212
  case "skill":
211
213
  return [formatSkillResult(raw)];
212
214
  case "read":
@@ -225,6 +227,21 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
225
227
  }
226
228
  }
227
229
 
230
+ function formatPlanResult(
231
+ raw: Extract<ToolRawResult, { kind: "update_plan"; ok: true }>,
232
+ ): string[] {
233
+ const lines: string[] = [];
234
+ if (raw.explanation !== undefined) {
235
+ lines.push(`${raw.explanation}\n`);
236
+ }
237
+ for (const step of raw.plan) {
238
+ const symbol =
239
+ step.status === "completed" ? "✓" : step.status === "in_progress" ? "→" : "•";
240
+ lines.push(` ${symbol} ${step.step}\n`);
241
+ }
242
+ return lines;
243
+ }
244
+
228
245
  function formatSkillResult(raw: Extract<ToolRawResult, { kind: "skill" }>): string {
229
246
  if (!raw.ok) {
230
247
  return `skill ${raw.name || "(unknown)"} failed -> ${boundedToolError(raw.error)}\n`;
@@ -16,6 +16,7 @@ import type {
16
16
  TaskOutputRawResult,
17
17
  TaskStopRawResult,
18
18
  ToolRawResult,
19
+ UpdatePlanRawResult,
19
20
  WebFetchRawResult,
20
21
  WebSearchRawResult,
21
22
  WriteFileRawResult,
@@ -48,6 +49,8 @@ export class ObservationBuilder {
48
49
  return { content: renderDeleteObservation(input.raw) };
49
50
  case "bash":
50
51
  return { content: renderBashObservation(input.raw) };
52
+ case "update_plan":
53
+ return { content: renderUpdatePlanObservation(input.raw) };
51
54
  case "task_list":
52
55
  return { content: renderTaskListObservation(input.raw) };
53
56
  case "task_output":
@@ -70,6 +73,10 @@ export class ObservationBuilder {
70
73
  }
71
74
  }
72
75
 
76
+ function renderUpdatePlanObservation(raw: UpdatePlanRawResult): string {
77
+ return raw.ok ? "Plan updated." : `UpdatePlan failed: ${raw.error}`;
78
+ }
79
+
73
80
  function assertNever(value: never): never {
74
81
  throw new Error(`Unhandled tool raw result: ${JSON.stringify(value)}`);
75
82
  }
@@ -4842,6 +4842,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4842
4842
  "glob",
4843
4843
  "grep",
4844
4844
  "bash",
4845
+ "update_plan",
4845
4846
  "task_list",
4846
4847
  "task_output",
4847
4848
  "task_input",
@@ -11,6 +11,7 @@ import { createTaskListToolExecutor } from "./task-list";
11
11
  import { createTaskInputToolExecutor } from "./task-input";
12
12
  import { createTaskOutputToolExecutor } from "./task-output-tool";
13
13
  import { createTaskStopToolExecutor } from "./task-stop";
14
+ import { createUpdatePlanToolExecutor } from "./update-plan";
14
15
  import { createWebFetchToolExecutor } from "./web-fetch";
15
16
  import type { Refiner } from "./web-fetch/refiner";
16
17
  import { createWebSearchToolExecutor } from "./web-search";
@@ -255,6 +256,7 @@ export function createDefaultTooling(options: {
255
256
  maxTimeoutMs: toolingConfig.bashMaxTimeoutMs,
256
257
  }),
257
258
  );
259
+ registry.register(createUpdatePlanToolExecutor());
258
260
  registry.register(createTaskListToolExecutor({ taskManager }));
259
261
  registry.register(createTaskOutputToolExecutor({ taskManager }));
260
262
  registry.register(createTaskInputToolExecutor({ taskManager }));
@@ -144,6 +144,24 @@ export type TaskListRawResult = {
144
144
  error?: string;
145
145
  };
146
146
 
147
+ export type PlanStepStatus = "pending" | "in_progress" | "completed";
148
+
149
+ export type PlanStep = {
150
+ step: string;
151
+ status: PlanStepStatus;
152
+ };
153
+
154
+ export type UpdatePlanRawResult =
155
+ | {
156
+ ok: true;
157
+ explanation?: string;
158
+ plan: PlanStep[];
159
+ }
160
+ | {
161
+ ok: false;
162
+ error: string;
163
+ };
164
+
147
165
  export type TaskOutputRawResult = {
148
166
  ok: boolean;
149
167
  taskId: string;
@@ -355,6 +373,7 @@ export type ToolRawResultByKind = {
355
373
  glob: GlobRawResult;
356
374
  grep: GrepRawResult;
357
375
  bash: BashRawResult;
376
+ update_plan: UpdatePlanRawResult;
358
377
  task_list: TaskListRawResult;
359
378
  task_output: TaskOutputRawResult;
360
379
  task_input: TaskInputRawResult;
@@ -0,0 +1,166 @@
1
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
+ import {
3
+ defineToolExecutor,
4
+ type PlanStep,
5
+ type ToolExecutionContext,
6
+ type ToolExecutor,
7
+ type UpdatePlanRawResult,
8
+ } from "./types";
9
+
10
+ const MAX_PLAN_ITEMS = 12;
11
+ const MAX_STEP_LENGTH = 200;
12
+ const MAX_EXPLANATION_LENGTH = 500;
13
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
14
+
15
+ export function createUpdatePlanToolExecutor(): ToolExecutor {
16
+ return defineToolExecutor("update_plan", {
17
+ definition: {
18
+ name: "UpdatePlan",
19
+ description:
20
+ "Replace the current task plan with a complete ordered list of steps and their progress. Use an optional explanation when revising the approach. At most one step may be in_progress.",
21
+ parameters: {
22
+ type: "object",
23
+ additionalProperties: false,
24
+ properties: {
25
+ explanation: {
26
+ type: "string",
27
+ maxLength: MAX_EXPLANATION_LENGTH,
28
+ description: "Optional explanation for this plan update.",
29
+ },
30
+ plan: {
31
+ type: "array",
32
+ maxItems: MAX_PLAN_ITEMS,
33
+ description: "The complete ordered task plan.",
34
+ items: {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ step: {
39
+ type: "string",
40
+ minLength: 1,
41
+ maxLength: MAX_STEP_LENGTH,
42
+ description: "Task step text.",
43
+ },
44
+ status: {
45
+ type: "string",
46
+ enum: ["pending", "in_progress", "completed"],
47
+ description: "Step status.",
48
+ },
49
+ },
50
+ required: ["step", "status"],
51
+ },
52
+ },
53
+ },
54
+ required: ["plan"],
55
+ },
56
+ },
57
+ async execute(
58
+ args,
59
+ _call,
60
+ context: ToolExecutionContext,
61
+ ): Promise<UpdatePlanRawResult> {
62
+ throwIfTurnCancelled(context.signal);
63
+ const parsed = parseUpdatePlanArgs(args);
64
+ if (!parsed.ok) {
65
+ return parsed;
66
+ }
67
+ throwIfTurnCancelled(context.signal);
68
+ return {
69
+ ok: true,
70
+ ...(parsed.explanation === undefined
71
+ ? {}
72
+ : { explanation: parsed.explanation }),
73
+ plan: parsed.plan,
74
+ };
75
+ },
76
+ });
77
+ }
78
+
79
+ type ParsedUpdatePlanArgs =
80
+ | { ok: true; explanation?: string; plan: PlanStep[] }
81
+ | { ok: false; error: string };
82
+
83
+ function parseUpdatePlanArgs(args: unknown): ParsedUpdatePlanArgs {
84
+ if (!isRecord(args)) {
85
+ return failure("UpdatePlan arguments must be an object.");
86
+ }
87
+ const unexpected = Object.keys(args).find(
88
+ (key) => key !== "explanation" && key !== "plan",
89
+ );
90
+ if (unexpected !== undefined) {
91
+ return failure(`UpdatePlan received unexpected argument: ${unexpected}.`);
92
+ }
93
+ if (!Array.isArray(args.plan)) {
94
+ return failure("UpdatePlan plan must be an array.");
95
+ }
96
+ if (args.plan.length > MAX_PLAN_ITEMS) {
97
+ return failure(`UpdatePlan accepts at most ${MAX_PLAN_ITEMS} steps.`);
98
+ }
99
+
100
+ let explanation: string | undefined;
101
+ if (args.explanation !== undefined) {
102
+ if (typeof args.explanation !== "string") {
103
+ return failure("UpdatePlan explanation must be a string.");
104
+ }
105
+ explanation = args.explanation.trim();
106
+ if (explanation.length > MAX_EXPLANATION_LENGTH) {
107
+ return failure(
108
+ `UpdatePlan explanation must be at most ${MAX_EXPLANATION_LENGTH} characters.`,
109
+ );
110
+ }
111
+ if (explanation === "") {
112
+ explanation = undefined;
113
+ }
114
+ }
115
+
116
+ const plan: PlanStep[] = [];
117
+ let inProgressCount = 0;
118
+ for (const [index, item] of args.plan.entries()) {
119
+ if (!isRecord(item)) {
120
+ return failure(`UpdatePlan plan[${index}] must be an object.`);
121
+ }
122
+ const unexpectedItemKey = Object.keys(item).find(
123
+ (key) => key !== "step" && key !== "status",
124
+ );
125
+ if (unexpectedItemKey !== undefined) {
126
+ return failure(
127
+ `UpdatePlan plan[${index}] received unexpected field: ${unexpectedItemKey}.`,
128
+ );
129
+ }
130
+ if (typeof item.step !== "string" || item.step.trim() === "") {
131
+ return failure(`UpdatePlan plan[${index}].step must be a non-empty string.`);
132
+ }
133
+ const step = item.step.trim();
134
+ if (step.length > MAX_STEP_LENGTH) {
135
+ return failure(
136
+ `UpdatePlan plan[${index}].step must be at most ${MAX_STEP_LENGTH} characters.`,
137
+ );
138
+ }
139
+ if (typeof item.status !== "string" || !STATUSES.has(item.status)) {
140
+ return failure(
141
+ `UpdatePlan plan[${index}].status must be pending, in_progress, or completed.`,
142
+ );
143
+ }
144
+ if (item.status === "in_progress") {
145
+ inProgressCount += 1;
146
+ }
147
+ plan.push({ step, status: item.status as PlanStep["status"] });
148
+ }
149
+ if (inProgressCount > 1) {
150
+ return failure("UpdatePlan allows at most one in_progress step.");
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ ...(explanation === undefined ? {} : { explanation }),
156
+ plan,
157
+ };
158
+ }
159
+
160
+ function failure(error: string): ParsedUpdatePlanArgs {
161
+ return { ok: false, error };
162
+ }
163
+
164
+ function isRecord(value: unknown): value is Record<string, unknown> {
165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ }
@@ -0,0 +1,43 @@
1
+ import { Box, Text } from "ink";
2
+ import type { TimelineItem } from "../event-store";
3
+
4
+ export function PlanView(props: { plan: NonNullable<TimelineItem["plan"]> }) {
5
+ return (
6
+ <Box flexDirection="column" marginLeft={2}>
7
+ {props.plan.explanation === undefined ? null : (
8
+ <Text dimColor italic>
9
+ {props.plan.explanation}
10
+ </Text>
11
+ )}
12
+ {props.plan.steps.length === 0 ? (
13
+ <Text dimColor italic>
14
+ (no steps)
15
+ </Text>
16
+ ) : (
17
+ props.plan.steps.map((step, index) => (
18
+ <PlanStepRow key={`${index}:${step.step}`} step={step} />
19
+ ))
20
+ )}
21
+ </Box>
22
+ );
23
+ }
24
+
25
+ function PlanStepRow(props: {
26
+ step: NonNullable<TimelineItem["plan"]>["steps"][number];
27
+ }) {
28
+ if (props.step.status === "completed") {
29
+ return (
30
+ <Text dimColor strikethrough>
31
+ ✓ {props.step.step}
32
+ </Text>
33
+ );
34
+ }
35
+ if (props.step.status === "in_progress") {
36
+ return (
37
+ <Text color="cyan" bold>
38
+ → {props.step.step}
39
+ </Text>
40
+ );
41
+ }
42
+ return <Text dimColor>• {props.step.step}</Text>;
43
+ }
@@ -5,6 +5,7 @@ import type { AssistantStreamSectionItem } from "../tui-projection-store";
5
5
  import { AssistantMarkdown } from "./assistant-markdown";
6
6
  import { BashResultView } from "./bash-result-view";
7
7
  import { DiffView } from "./diff-view";
8
+ import { PlanView } from "./plan-view";
8
9
 
9
10
  export type TimelineProps = {
10
11
  items: readonly TimelineItem[];
@@ -42,6 +43,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
42
43
  )}
43
44
  {renderItemBash(item)}
44
45
  {renderItemDiff(item)}
46
+ {renderItemPlan(item)}
45
47
  </Fragment>
46
48
  );
47
49
  }
@@ -51,6 +53,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
51
53
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
52
54
  {renderItemBash(item)}
53
55
  {renderItemDiff(item)}
56
+ {renderItemPlan(item)}
54
57
  </Fragment>
55
58
  );
56
59
  }
@@ -105,6 +108,10 @@ function renderItemBash(item: TimelineItem) {
105
108
  return <BashResultView detail={item.bash} />;
106
109
  }
107
110
 
111
+ function renderItemPlan(item: TimelineItem) {
112
+ return item.plan === undefined ? null : <PlanView plan={item.plan} />;
113
+ }
114
+
108
115
  function formatTimelineItem(item: TimelineItem): string {
109
116
  if (item.status === "text") {
110
117
  return item.text;
@@ -11,7 +11,7 @@ import type {
11
11
  } from "../model/model-context-profile";
12
12
  import type { ShellTaskSnapshot, ShellTaskStatus } from "../tools/bash-task";
13
13
  import { countPatchChanges } from "../tools/file-diff";
14
- import type { DiffHunk, ToolRawResult } from "../tools/types";
14
+ import type { DiffHunk, PlanStep, ToolRawResult } from "../tools/types";
15
15
  import {
16
16
  defaultTuiProjectionPolicy,
17
17
  type TuiProjectionPolicy,
@@ -33,6 +33,10 @@ export type TimelineItem = {
33
33
  diffTruncated?: boolean;
34
34
  bash?: BashDisplayDetail;
35
35
  userPrompt?: UserPromptProjection;
36
+ plan?: {
37
+ explanation?: string;
38
+ steps: readonly PlanStep[];
39
+ };
36
40
  };
37
41
 
38
42
  export type TuiTurnProjection = {
@@ -747,11 +751,12 @@ export function toolCallStartedProjection(input: {
747
751
  export function toolRawResultProjection(
748
752
  input: { name: string; args: unknown },
749
753
  raw: ToolRawResult,
750
- ): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated"> {
754
+ ): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated" | "plan"> {
751
755
  return {
752
756
  text: toolRawResultSummary(input.name, input.args, raw),
753
757
  ...toolRawResultDiff(raw),
754
758
  ...toolRawResultBashDetail(raw),
759
+ ...toolRawResultPlanDetail(raw),
755
760
  };
756
761
  }
757
762
 
@@ -888,6 +893,13 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
888
893
  : `${base} -> running ${raw.outputFilePath}`;
889
894
  }
890
895
  return raw.exitCode === undefined ? base : `${base} -> exit ${raw.exitCode}`;
896
+ case "update_plan": {
897
+ if (!raw.ok) {
898
+ return base;
899
+ }
900
+ const completed = raw.plan.filter((step) => step.status === "completed").length;
901
+ return `${base} -> ${completed}/${raw.plan.length} completed`;
902
+ }
891
903
  case "mcp":
892
904
  case "generic":
893
905
  return base;
@@ -929,6 +941,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
929
941
  case "delete":
930
942
  case "glob":
931
943
  case "grep":
944
+ case "update_plan":
932
945
  case "task_list":
933
946
  case "task_stop":
934
947
  case "web_search":
@@ -964,6 +977,7 @@ function toolRawResultDiff(
964
977
  case "glob":
965
978
  case "grep":
966
979
  case "bash":
980
+ case "update_plan":
967
981
  case "task_list":
968
982
  case "task_output":
969
983
  case "task_input":
@@ -981,6 +995,18 @@ function toolRawResultDiff(
981
995
  }
982
996
  }
983
997
 
998
+ function toolRawResultPlanDetail(raw: ToolRawResult): Pick<TimelineItem, "plan"> {
999
+ if (raw.kind !== "update_plan" || !raw.ok) {
1000
+ return {};
1001
+ }
1002
+ return {
1003
+ plan: {
1004
+ ...(raw.explanation === undefined ? {} : { explanation: raw.explanation }),
1005
+ steps: raw.plan.map((step) => ({ ...step })),
1006
+ },
1007
+ };
1008
+ }
1009
+
984
1010
  function upsertBackgroundTask(
985
1011
  tasks: ShellTaskSnapshot[],
986
1012
  task: ShellTaskSnapshot,