@diffexai/diffex 0.3.0 → 0.3.2

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 (96) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/args.d.ts +2 -0
  3. package/dist/cli/args.js +39 -0
  4. package/dist/core/agent-session-services.d.ts +1 -0
  5. package/dist/core/agent-session-services.js +1 -0
  6. package/dist/core/agent-session.d.ts +4 -5
  7. package/dist/core/agent-session.js +11 -62
  8. package/dist/core/compaction/branch-summarization.js +3 -2
  9. package/dist/core/compaction/compaction.js +9 -7
  10. package/dist/core/compaction/utils.d.ts +3 -0
  11. package/dist/core/compaction/utils.js +21 -0
  12. package/dist/core/current-plan-state.d.ts +3 -2
  13. package/dist/core/current-plan-state.js +23 -6
  14. package/dist/core/plan-snapshot.d.ts +3 -0
  15. package/dist/core/plan-snapshot.js +23 -0
  16. package/dist/core/sdk.d.ts +3 -0
  17. package/dist/core/sdk.js +1 -0
  18. package/dist/core/session-manager.js +21 -1
  19. package/dist/core/subagents/services.d.ts +2 -0
  20. package/dist/core/subagents/services.js +2 -0
  21. package/dist/core/tools/bash.js +47 -12
  22. package/dist/core/tools/update-plan.d.ts +27 -6
  23. package/dist/core/tools/update-plan.js +122 -18
  24. package/dist/index.d.ts +1 -0
  25. package/dist/main.js +4 -0
  26. package/dist/modes/interactive/components/footer.js +16 -5
  27. package/dist/modes/interactive/components/tool-group.d.ts +1 -0
  28. package/dist/modes/interactive/components/tool-group.js +26 -7
  29. package/dist/modes/print-mode.js +16 -13
  30. package/dist/node_modules/@diffexai/diffex-agent-core/dist/agent-loop.js +21 -3
  31. package/dist/node_modules/@diffexai/diffex-agent-core/dist/types.d.ts +17 -10
  32. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-components.json +4 -4
  33. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-files.json +3 -3
  34. package/dist/node_modules/@diffexai/diffex-agent-core/package.json +1 -1
  35. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/.manifest.json +1 -1
  36. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/amazon-bedrock.json +1 -1
  37. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/anthropic.json +1 -1
  38. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/azure-openai-responses.json +1 -1
  39. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cerebras.json +1 -1
  40. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  41. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cloudflare-workers-ai.json +1 -1
  42. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/fireworks.json +1 -1
  43. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/github-copilot.json +1 -1
  44. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/google-vertex.json +1 -1
  45. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/google.json +1 -1
  46. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/huggingface.json +1 -1
  47. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/minimax-cn.json +1 -1
  48. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/minimax.json +1 -1
  49. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/mistral.json +1 -1
  50. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/moonshotai-cn.json +1 -1
  51. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/moonshotai.json +1 -1
  52. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/nvidia.json +1 -1
  53. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openai.json +1 -1
  54. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode-go.json +1 -1
  55. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode.json +1 -1
  56. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openrouter.json +1 -1
  57. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/together.json +1 -1
  58. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  59. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xai.json +1 -1
  60. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-ams.json +1 -1
  61. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-cn.json +1 -1
  62. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi-token-plan-sgp.json +1 -1
  63. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/xiaomi.json +1 -1
  64. package/dist/node_modules/@diffexai/diffex-ai/distribution-components.json +3 -3
  65. package/dist/node_modules/@diffexai/diffex-ai/distribution-files.json +30 -30
  66. package/dist/node_modules/@diffexai/diffex-ai/package.json +1 -1
  67. package/dist/node_modules/@diffexai/diffex-client/distribution-components.json +3 -3
  68. package/dist/node_modules/@diffexai/diffex-client/distribution-files.json +1 -1
  69. package/dist/node_modules/@diffexai/diffex-client/package.json +1 -1
  70. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-components.json +2 -2
  71. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-files.json +1 -1
  72. package/dist/node_modules/@diffexai/diffex-harness-state/package.json +1 -1
  73. package/dist/node_modules/@diffexai/diffex-protocol/distribution-components.json +2 -2
  74. package/dist/node_modules/@diffexai/diffex-protocol/distribution-files.json +1 -1
  75. package/dist/node_modules/@diffexai/diffex-protocol/package.json +1 -1
  76. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-components.json +2 -2
  77. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-files.json +1 -1
  78. package/dist/node_modules/@diffexai/diffex-telemetry/package.json +1 -1
  79. package/dist/node_modules/@diffexai/diffex-tui/distribution-components.json +2 -2
  80. package/dist/node_modules/@diffexai/diffex-tui/distribution-files.json +1 -1
  81. package/dist/node_modules/@diffexai/diffex-tui/package.json +1 -1
  82. package/dist/utils/child-process.d.ts +5 -1
  83. package/dist/utils/child-process.js +9 -1
  84. package/dist/utils/owned-shell-processes.d.ts +23 -2
  85. package/dist/utils/owned-shell-processes.js +149 -39
  86. package/dist/utils/shell-process-identity.d.ts +11 -0
  87. package/dist/utils/shell-process-identity.js +61 -0
  88. package/dist/utils/shell.d.ts +1 -1
  89. package/dist/utils/shell.js +5 -5
  90. package/distribution-components.json +11 -11
  91. package/distribution-files.json +101 -85
  92. package/npm-shrinkwrap.json +2 -2
  93. package/package.json +1 -1
  94. package/release/distribution-manifest.json +4 -4
  95. package/release/install-package-lock.json +5 -5
  96. package/release/install-package.json +2 -2
@@ -7,7 +7,9 @@ import { createInterface } from "readline";
7
7
  import { StringDecoder } from "string_decoder";
8
8
  import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
9
9
  import { normalizePath, resolvePath } from "../utils/paths.js";
10
+ import { excludePlanItems } from "./compaction/utils.js";
10
11
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
12
+ import { stripCurrentPlanSnapshot } from "./plan-snapshot.js";
11
13
  export const CURRENT_SESSION_VERSION = 3;
12
14
  function createSessionId() {
13
15
  return uuidv7();
@@ -237,7 +239,25 @@ export function buildContextEntries(entries, leafId, byId) {
237
239
  export function buildSessionContext(entries, leafId, byId) {
238
240
  const path = buildSessionPath(entries, leafId, byId);
239
241
  const { thinkingLevel, model } = getSessionContextSettings(path);
240
- const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
242
+ const contextEntries = buildContextEntries(entries, leafId, byId);
243
+ const contextEntryIds = new Set(contextEntries.map((entry) => entry.id));
244
+ // Context order puts the latest compaction before its retained tail. Use path
245
+ // order to find the snapshot that supersedes earlier plan calls and summaries.
246
+ let snapshotIndex = -1;
247
+ for (let i = path.length - 1; i >= 0; i--) {
248
+ const entry = path[i];
249
+ if (contextEntryIds.has(entry.id) &&
250
+ (entry.type === "compaction" || entry.type === "branch_summary") &&
251
+ stripCurrentPlanSnapshot(entry.summary) !== entry.summary) {
252
+ snapshotIndex = i;
253
+ break;
254
+ }
255
+ }
256
+ const supersededEntryIds = new Set(path.slice(0, Math.max(0, snapshotIndex)).map((entry) => entry.id));
257
+ const messages = contextEntries.flatMap((entry) => {
258
+ const projected = sessionEntryToContextMessages(entry);
259
+ return supersededEntryIds.has(entry.id) ? excludePlanItems(projected) : projected;
260
+ });
241
261
  return { messages, thinkingLevel, model };
242
262
  }
243
263
  /**
@@ -1,6 +1,7 @@
1
1
  import type { TelemetryContext, ThinkingLevel } from "@diffexai/diffex-agent-core";
2
2
  import type { Model } from "@diffexai/diffex-ai";
3
3
  import type { HarnessStateStore, HarnessVersionBundle } from "@diffexai/diffex-harness-state";
4
+ import type { BackgroundProcessPolicy } from "../../utils/owned-shell-processes.ts";
4
5
  import type { AgentSession } from "../agent-session.ts";
5
6
  import { type AgentSessionServices } from "../agent-session-services.ts";
6
7
  import type { ModelRuntime } from "../model-runtime.ts";
@@ -19,6 +20,7 @@ export interface CreateSubagentServicesOptions {
19
20
  telemetryContext?: TelemetryContext;
20
21
  }
21
22
  export interface SubagentSessionConfig {
23
+ readonly backgroundProcessPolicy?: BackgroundProcessPolicy;
22
24
  readonly cwd: string;
23
25
  readonly agentDir: string;
24
26
  readonly modelRuntime: ModelRuntime;
@@ -39,6 +39,7 @@ export function captureSubagentSessionConfig(parent, agentDir) {
39
39
  .getActiveToolNames()
40
40
  .filter((name) => allToolNames.has(name) && toolsByName.get(name)?.sourceInfo.source === "builtin"));
41
41
  return Object.freeze({
42
+ backgroundProcessPolicy: parent.backgroundProcessPolicy,
42
43
  cwd: parent.sessionManager.getCwd(),
43
44
  agentDir,
44
45
  modelRuntime: parent.modelRuntime,
@@ -133,6 +134,7 @@ export async function createSubagentSession(config, options = {}) {
133
134
  markFixedHarnessSubagentSession(sessionManager);
134
135
  const { session } = await createInternalAgentSessionFromServices({
135
136
  services,
137
+ backgroundProcessPolicy: config.backgroundProcessPolicy,
136
138
  sessionManager,
137
139
  model: config.model,
138
140
  thinkingLevel: config.thinkingLevel,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { constants } from "node:fs";
2
3
  import { access as fsAccess } from "node:fs/promises";
3
4
  import { Container, Text, truncateToWidth } from "@diffexai/diffex-tui";
@@ -8,6 +9,7 @@ import { truncateToVisualLines } from "../../modes/interactive/components/visual
8
9
  import { theme } from "../../modes/interactive/theme/theme.js";
9
10
  import { waitForChildProcess } from "../../utils/child-process.js";
10
11
  import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.js";
12
+ import { SHELL_EXECUTION_ID_ENV } from "../../utils/shell-process-identity.js";
11
13
  import { OutputAccumulator } from "./output-accumulator.js";
12
14
  import { getTextOutput, invalidArgText, str } from "./render-utils.js";
13
15
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
@@ -56,10 +58,11 @@ export function createLocalBashOperations(options) {
56
58
  }
57
59
  signal?.throwIfAborted();
58
60
  const commandFromStdin = shellConfig.commandTransport === "stdin";
61
+ const executionId = randomUUID();
59
62
  const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
60
63
  cwd,
61
64
  detached: process.platform !== "win32",
62
- env: env ?? getShellEnv(),
65
+ env: { ...(env ?? getShellEnv()), [SHELL_EXECUTION_ID_ENV]: executionId },
63
66
  stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
64
67
  windowsHide: true,
65
68
  });
@@ -67,25 +70,52 @@ export function createLocalBashOperations(options) {
67
70
  child.stdin?.on("error", () => { });
68
71
  child.stdin?.end(command);
69
72
  }
70
- if (child.pid) {
71
- if (options?.owner)
72
- options.owner.track(child.pid);
73
- else
74
- trackDetachedChildPid(child.pid);
75
- }
76
73
  let timedOut = false;
74
+ const environmentLifetime = options?.owner?.policy.lifetime === "environment";
75
+ let completed = false;
76
+ let completionError;
77
77
  let timeoutHandle;
78
78
  const onAbort = () => {
79
- if (child.pid)
80
- killProcessTree(child.pid);
79
+ if (child.pid) {
80
+ if (options?.owner)
81
+ options.owner.terminate(child.pid);
82
+ else
83
+ killProcessTree(child.pid);
84
+ }
81
85
  };
86
+ const onExit = (code) => {
87
+ completed = true;
88
+ if (environmentLifetime && timeoutHandle)
89
+ clearTimeout(timeoutHandle);
90
+ try {
91
+ if (child.pid)
92
+ options?.owner?.complete(child.pid, code === 0 && !timedOut && !signal?.aborted);
93
+ }
94
+ catch (error) {
95
+ completionError = error;
96
+ try {
97
+ onAbort();
98
+ }
99
+ catch (cleanupError) {
100
+ completionError = new AggregateError([error, cleanupError], "Shell ownership handoff failed");
101
+ }
102
+ }
103
+ };
104
+ child.once("exit", onExit);
82
105
  try {
106
+ if (child.pid) {
107
+ if (options?.owner)
108
+ options.owner.track(child.pid, executionId);
109
+ else
110
+ trackDetachedChildPid(child.pid);
111
+ }
83
112
  // Set timeout if provided.
84
113
  if (timeoutMs !== undefined) {
85
114
  timeoutHandle = setTimeout(() => {
115
+ if (environmentLifetime && completed)
116
+ return;
86
117
  timedOut = true;
87
- if (child.pid)
88
- killProcessTree(child.pid);
118
+ onAbort();
89
119
  }, timeoutMs);
90
120
  }
91
121
  // Stream stdout and stderr.
@@ -100,7 +130,11 @@ export function createLocalBashOperations(options) {
100
130
  }
101
131
  // Handle shell spawn errors and wait for the process to terminate without hanging
102
132
  // on inherited stdio handles held by detached descendants.
103
- const exitCode = await waitForChildProcess(child);
133
+ const exitCode = await waitForChildProcess(child, {
134
+ maxPostExitMs: environmentLifetime ? 100 : undefined,
135
+ });
136
+ if (completionError)
137
+ throw completionError;
104
138
  if (signal?.aborted) {
105
139
  throw new Error("aborted");
106
140
  }
@@ -110,6 +144,7 @@ export function createLocalBashOperations(options) {
110
144
  return { exitCode };
111
145
  }
112
146
  finally {
147
+ child.removeListener("exit", onExit);
113
148
  if (child.pid) {
114
149
  if (options?.owner)
115
150
  options.owner.releaseIfExited(child.pid);
@@ -4,30 +4,51 @@ import type { ToolDefinition } from "../extensions/types.ts";
4
4
  declare const UPDATE_PLAN_STATUSES: readonly ["pending", "in_progress", "completed"];
5
5
  export type UpdatePlanStatus = (typeof UPDATE_PLAN_STATUSES)[number];
6
6
  export interface UpdatePlanItem {
7
+ id: string;
7
8
  step: string;
8
9
  status: UpdatePlanStatus;
10
+ success_criteria?: string[];
9
11
  }
10
12
  export type UpdatePlanToolInput = {
11
13
  plan: UpdatePlanItem[];
12
14
  explanation?: string;
15
+ } | {
16
+ update: {
17
+ id: string;
18
+ step?: string;
19
+ status?: UpdatePlanStatus;
20
+ success_criteria?: string[];
21
+ };
22
+ explanation?: string;
13
23
  } | {
14
24
  clear: true;
15
25
  explanation?: string;
16
26
  };
17
- export type UpdatePlanToolDetails = UpdatePlanToolInput;
27
+ export type UpdatePlanToolDetails = Exclude<UpdatePlanToolInput, {
28
+ update: unknown;
29
+ }>;
18
30
  declare const updatePlanSchema: Type.TObject<{
19
31
  plan: Type.TOptional<Type.TArray<Type.TObject<{
32
+ id: Type.TString;
20
33
  step: Type.TString;
21
34
  status: Type.TUnsafe<"completed" | "in_progress" | "pending">;
35
+ success_criteria: Type.TOptional<Type.TArray<Type.TString>>;
22
36
  }>>>;
37
+ update: Type.TOptional<Type.TObject<{
38
+ id: Type.TString;
39
+ step: Type.TOptional<Type.TString>;
40
+ status: Type.TOptional<Type.TUnsafe<"completed" | "in_progress" | "pending">>;
41
+ success_criteria: Type.TOptional<Type.TArray<Type.TString>>;
42
+ }>>;
23
43
  clear: Type.TOptional<Type.TLiteral<true>>;
24
44
  explanation: Type.TOptional<Type.TString>;
25
45
  }>;
26
46
  export declare const updatePlanToolSystemPromptContribution: {
27
- readonly snippet: "track progress with an ordered task checklist";
28
- readonly guidelines: readonly ["Use update_plan for complex, ambiguous, dependent, long-running, or multi-part work, when the user requests a plan, or when checkpoints would improve collaboration; skip it for simple or immediately executable work.", "Write short, ordered, outcome-focused steps with observable evidence where useful; do not narrate commands or pad the checklist with obvious actions.", "Include final verification when correctness requires it.", "Update the checklist at meaningful milestones and mark completed work before moving on.", "Explain material replanning, omit explanations for routine progress, and use clear: true only when abandoning the checklist.", "Publishing the checklist communicates progress but does not execute, schedule, or prove completion of its steps."];
47
+ readonly snippet: "persist an ordered plan with step IDs and optional success criteria";
48
+ readonly guidelines: readonly ["Use update_plan for complex, ambiguous, dependent, long-running, or multi-part work, when the user requests a plan, or when checkpoints would improve collaboration; skip it for simple or immediately executable work.", "Write short, ordered, outcome-focused steps. Give each step a stable ID and add optional success_criteria for subtasks needing validation.", "Include final verification when correctness requires it.", "Update by ID at meaningful milestones; retain IDs when rewording, reordering, or changing status.", "Explain material replanning, omit explanations for routine progress, and use clear: true only when abandoning the checklist.", "Publishing the checklist communicates progress but does not execute, schedule, or prove completion of its steps."];
29
49
  };
30
- export declare function parseUpdatePlanOperation(input: unknown): UpdatePlanToolDetails;
31
- export declare function createUpdatePlanToolDefinition(): ToolDefinition<typeof updatePlanSchema, UpdatePlanToolDetails>;
32
- export declare function createUpdatePlanTool(): AgentTool<typeof updatePlanSchema>;
50
+ export declare function parseUpdatePlanOperation(input: unknown): UpdatePlanToolInput;
51
+ export declare function resolveUpdatePlanOperation(input: unknown, currentPlan?: readonly UpdatePlanItem[]): UpdatePlanToolDetails;
52
+ export declare function createUpdatePlanToolDefinition(_cwd?: string, getCurrentPlan?: () => readonly UpdatePlanItem[] | undefined): ToolDefinition<typeof updatePlanSchema, UpdatePlanToolDetails>;
53
+ export declare function createUpdatePlanTool(getCurrentPlan?: () => readonly UpdatePlanItem[] | undefined): AgentTool<typeof updatePlanSchema>;
33
54
  export {};
@@ -9,26 +9,34 @@ const updatePlanStatusSchema = Type.Unsafe({
9
9
  description: "Status of this plan item.",
10
10
  });
11
11
  const updatePlanItemSchema = Type.Object({
12
+ id: Type.String({ description: "Stable step ID; retain it across edits and reordering." }),
12
13
  step: Type.String({ description: "Task step text." }),
13
14
  status: updatePlanStatusSchema,
15
+ success_criteria: Type.Optional(Type.Array(Type.String(), { description: "Observable completion criteria." })),
14
16
  }, { additionalProperties: false });
15
17
  const updatePlanSchema = Type.Object({
16
18
  plan: Type.Optional(Type.Array(updatePlanItemSchema, {
17
19
  minItems: 1,
18
- description: "Full ordered plan snapshot. Provide exactly one operation: plan or clear.",
20
+ description: "Full ordered plan snapshot. Keep existing IDs for retained steps.",
19
21
  })),
22
+ update: Type.Optional(Type.Object({
23
+ id: Type.String({ description: "ID of an existing step." }),
24
+ step: Type.Optional(Type.String()),
25
+ status: Type.Optional(updatePlanStatusSchema),
26
+ success_criteria: Type.Optional(Type.Array(Type.String())),
27
+ }, { additionalProperties: false })),
20
28
  clear: Type.Optional(Type.Literal(true, {
21
- description: "Explicitly abandon and clear the current plan. Provide exactly one operation: plan or clear.",
29
+ description: "Explicitly abandon and clear the current plan.",
22
30
  })),
23
31
  explanation: Type.Optional(Type.String({ description: "Reason for changing or clearing the plan." })),
24
32
  }, { additionalProperties: false });
25
33
  export const updatePlanToolSystemPromptContribution = {
26
- snippet: "track progress with an ordered task checklist",
34
+ snippet: "persist an ordered plan with step IDs and optional success criteria",
27
35
  guidelines: [
28
36
  "Use update_plan for complex, ambiguous, dependent, long-running, or multi-part work, when the user requests a plan, or when checkpoints would improve collaboration; skip it for simple or immediately executable work.",
29
- "Write short, ordered, outcome-focused steps with observable evidence where useful; do not narrate commands or pad the checklist with obvious actions.",
37
+ "Write short, ordered, outcome-focused steps. Give each step a stable ID and add optional success_criteria for subtasks needing validation.",
30
38
  "Include final verification when correctness requires it.",
31
- "Update the checklist at meaningful milestones and mark completed work before moving on.",
39
+ "Update by ID at meaningful milestones; retain IDs when rewording, reordering, or changing status.",
32
40
  "Explain material replanning, omit explanations for routine progress, and use clear: true only when abandoning the checklist.",
33
41
  "Publishing the checklist communicates progress but does not execute, schedule, or prove completion of its steps.",
34
42
  ],
@@ -39,28 +47,52 @@ function isRecord(value) {
39
47
  function isUpdatePlanStatus(value) {
40
48
  return typeof value === "string" && UPDATE_PLAN_STATUSES.includes(value);
41
49
  }
50
+ function validateCriteria(value, label) {
51
+ if (!Array.isArray(value) ||
52
+ value.some((criterion) => typeof criterion !== "string" || criterion.trim().length === 0)) {
53
+ throw new Error(`${label} must be an array of non-empty strings.`);
54
+ }
55
+ return [...value];
56
+ }
42
57
  function validatePlanSnapshot(rawPlan) {
43
58
  if (!Array.isArray(rawPlan) || rawPlan.length === 0) {
44
59
  throw new Error("Plan must contain at least one item.");
45
60
  }
46
61
  const plan = [];
62
+ const ids = new Set();
47
63
  for (let index = 0; index < rawPlan.length; index++) {
48
64
  const rawItem = rawPlan[index];
49
65
  if (!isRecord(rawItem)) {
50
66
  throw new Error(`Plan item ${index + 1} must be an object.`);
51
67
  }
52
- const unexpectedField = Object.keys(rawItem).find((field) => field !== "step" && field !== "status");
68
+ const unexpectedField = Object.keys(rawItem).find((field) => field !== "id" && field !== "step" && field !== "status" && field !== "success_criteria");
53
69
  if (unexpectedField !== undefined) {
54
70
  throw new Error(`Plan item ${index + 1} contains unexpected field: ${unexpectedField}.`);
55
71
  }
56
- const { step, status } = rawItem;
72
+ const { id, step, status } = rawItem;
73
+ if (typeof id !== "string" || id.trim().length === 0) {
74
+ throw new Error(`Plan item ${index + 1} id must be a non-empty string.`);
75
+ }
76
+ if (ids.has(id)) {
77
+ throw new Error(`Duplicate plan item id: ${id}.`);
78
+ }
79
+ ids.add(id);
57
80
  if (typeof step !== "string" || step.trim().length === 0) {
58
81
  throw new Error(`Plan item ${index + 1} step must be a non-empty string.`);
59
82
  }
60
83
  if (!isUpdatePlanStatus(status)) {
61
84
  throw new Error(`Plan item ${index + 1} status must be pending, in_progress, or completed.`);
62
85
  }
63
- plan.push({ step, status });
86
+ plan.push({
87
+ id,
88
+ step,
89
+ status,
90
+ ...(Object.hasOwn(rawItem, "success_criteria")
91
+ ? {
92
+ success_criteria: validateCriteria(rawItem.success_criteria, `Plan item ${index + 1} success_criteria`),
93
+ }
94
+ : {}),
95
+ });
64
96
  }
65
97
  const counts = countPlanStatuses(plan);
66
98
  if (counts.completed !== plan.length && counts.inProgress !== 1) {
@@ -72,14 +104,15 @@ export function parseUpdatePlanOperation(input) {
72
104
  if (!isRecord(input)) {
73
105
  throw new Error("update_plan input must be an object.");
74
106
  }
75
- const unexpectedField = Object.keys(input).find((field) => field !== "plan" && field !== "clear" && field !== "explanation");
107
+ const unexpectedField = Object.keys(input).find((field) => field !== "plan" && field !== "update" && field !== "clear" && field !== "explanation");
76
108
  if (unexpectedField !== undefined) {
77
109
  throw new Error(`Unexpected update_plan field: ${unexpectedField}.`);
78
110
  }
79
111
  const hasPlan = Object.hasOwn(input, "plan");
112
+ const hasUpdate = Object.hasOwn(input, "update");
80
113
  const hasClear = Object.hasOwn(input, "clear");
81
- if (hasPlan === hasClear) {
82
- throw new Error("Provide exactly one update_plan operation: plan or clear.");
114
+ if (Number(hasPlan) + Number(hasUpdate) + Number(hasClear) !== 1) {
115
+ throw new Error("Provide exactly one update_plan operation: plan, update, or clear.");
83
116
  }
84
117
  const hasExplanation = Object.hasOwn(input, "explanation");
85
118
  if (hasExplanation && typeof input.explanation !== "string") {
@@ -92,9 +125,64 @@ export function parseUpdatePlanOperation(input) {
92
125
  }
93
126
  return hasExplanation ? { clear: true, explanation } : { clear: true };
94
127
  }
128
+ if (hasUpdate) {
129
+ if (!isRecord(input.update))
130
+ throw new Error("Update must be an object.");
131
+ const unexpectedUpdateField = Object.keys(input.update).find((field) => field !== "id" && field !== "step" && field !== "status" && field !== "success_criteria");
132
+ if (unexpectedUpdateField !== undefined)
133
+ throw new Error(`Unexpected update field: ${unexpectedUpdateField}.`);
134
+ const { id, step, status } = input.update;
135
+ if (typeof id !== "string" || id.trim().length === 0)
136
+ throw new Error("Update id must be a non-empty string.");
137
+ if (step !== undefined && (typeof step !== "string" || step.trim().length === 0)) {
138
+ throw new Error("Update step must be a non-empty string.");
139
+ }
140
+ if (status !== undefined && !isUpdatePlanStatus(status)) {
141
+ throw new Error("Update status must be pending, in_progress, or completed.");
142
+ }
143
+ const hasCriteria = Object.hasOwn(input.update, "success_criteria");
144
+ if (step === undefined && status === undefined && !hasCriteria)
145
+ throw new Error("Update must change a step field.");
146
+ const update = {
147
+ id,
148
+ ...(step === undefined ? {} : { step }),
149
+ ...(status === undefined ? {} : { status }),
150
+ ...(hasCriteria
151
+ ? { success_criteria: validateCriteria(input.update.success_criteria, "Update success_criteria") }
152
+ : {}),
153
+ };
154
+ return hasExplanation ? { update, explanation } : { update };
155
+ }
95
156
  const plan = validatePlanSnapshot(input.plan);
96
157
  return hasExplanation ? { plan, explanation } : { plan };
97
158
  }
159
+ export function resolveUpdatePlanOperation(input, currentPlan) {
160
+ const operation = parseUpdatePlanOperation(input);
161
+ if (!("update" in operation))
162
+ return operation;
163
+ if (!currentPlan)
164
+ throw new Error("Cannot update a step without a current plan.");
165
+ const index = currentPlan.findIndex((item) => item.id === operation.update.id);
166
+ if (index < 0)
167
+ throw new Error(`Unknown plan step id: ${operation.update.id}.`);
168
+ const plan = currentPlan.map((item, itemIndex) => {
169
+ if (itemIndex !== index)
170
+ return item;
171
+ const { success_criteria, ...changes } = operation.update;
172
+ const updated = { ...item, ...changes };
173
+ if (success_criteria !== undefined) {
174
+ if (success_criteria.length === 0)
175
+ delete updated.success_criteria;
176
+ else
177
+ updated.success_criteria = success_criteria;
178
+ }
179
+ return updated;
180
+ });
181
+ return {
182
+ plan: validatePlanSnapshot(plan),
183
+ ...(operation.explanation === undefined ? {} : { explanation: operation.explanation }),
184
+ };
185
+ }
98
186
  function countPlanStatuses(plan) {
99
187
  let completed = 0;
100
188
  let inProgress = 0;
@@ -151,27 +239,43 @@ function formatPlanCall(args, theme, showInvalidArgs) {
151
239
  if ("clear" in details) {
152
240
  return `${text}\n${theme.fg("toolOutput", "Plan cleared.")}`;
153
241
  }
242
+ if ("update" in details) {
243
+ text += `\n${theme.fg("toolOutput", `Update ${details.update.id}`)}`;
244
+ if (details.update.status)
245
+ text += ` ${formatPlanMarker(details.update.status, theme)}`;
246
+ if (details.update.step)
247
+ text += ` ${formatPlanStep(details.update.step, theme)}`;
248
+ if (details.update.success_criteria) {
249
+ for (const criterion of details.update.success_criteria)
250
+ text += `\n ${theme.fg("toolOutput", `- ${criterion}`)}`;
251
+ }
252
+ return text;
253
+ }
154
254
  for (const item of details.plan) {
155
- text += `\n${formatPlanMarker(item.status, theme)} ${formatPlanStep(item.step, theme)}`;
255
+ text += `\n${formatPlanMarker(item.status, theme)} ${theme.fg("toolOutput", item.id)} ${formatPlanStep(item.step, theme)}`;
256
+ for (const criterion of item.success_criteria ?? [])
257
+ text += `\n ${theme.fg("toolOutput", `- ${criterion}`)}`;
156
258
  }
157
259
  return text;
158
260
  }
159
- export function createUpdatePlanToolDefinition() {
261
+ export function createUpdatePlanToolDefinition(_cwd, getCurrentPlan) {
160
262
  return {
161
263
  name: "update_plan",
162
264
  label: "update_plan",
163
- description: "Replace or explicitly clear the full ordered task checklist. Unfinished plans require exactly one in_progress item.",
265
+ description: "Write or clear a plan, or update one step by ID. Use optional success_criteria for validation.",
164
266
  promptSnippet: updatePlanToolSystemPromptContribution.snippet,
165
267
  promptGuidelines: [...updatePlanToolSystemPromptContribution.guidelines],
166
268
  parameters: updatePlanSchema,
167
269
  executionMode: "sequential",
168
270
  async execute(_toolCallId, input) {
169
- const details = parseUpdatePlanOperation(input);
271
+ const details = resolveUpdatePlanOperation(input, getCurrentPlan?.());
170
272
  return {
171
273
  content: [
172
274
  {
173
275
  type: "text",
174
- text: "clear" in details ? "Plan cleared." : formatPlanSummary(details.plan),
276
+ text: "clear" in details
277
+ ? "Plan cleared."
278
+ : `${formatPlanSummary(details.plan)}\n${JSON.stringify({ plan: details.plan }, null, 2)}`,
175
279
  },
176
280
  ],
177
281
  details,
@@ -188,6 +292,6 @@ export function createUpdatePlanToolDefinition() {
188
292
  },
189
293
  };
190
294
  }
191
- export function createUpdatePlanTool() {
192
- return wrapToolDefinition(createUpdatePlanToolDefinition());
295
+ export function createUpdatePlanTool(getCurrentPlan) {
296
+ return wrapToolDefinition(createUpdatePlanToolDefinition(undefined, getCurrentPlan));
193
297
  }
package/dist/index.d.ts CHANGED
@@ -33,4 +33,5 @@ export { copyToClipboard } from "./utils/clipboard.ts";
33
33
  export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts";
34
34
  export { convertToPng } from "./utils/image-convert.ts";
35
35
  export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts";
36
+ export type { BackgroundProcessPolicy, ShellOwnershipRecord } from "./utils/owned-shell-processes.ts";
36
37
  export { getShellConfig } from "./utils/shell.ts";
package/dist/main.js CHANGED
@@ -439,6 +439,9 @@ export async function main(args, options) {
439
439
  : undefined;
440
440
  const trustPromptMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
441
441
  const projectTrustByCwd = new Map();
442
+ const backgroundProcessPolicy = parsed.backgroundProcessLifetime === "environment"
443
+ ? { lifetime: "environment", ownershipDirectory: resolvePath(parsed.backgroundProcessOwnershipDir, cwd) }
444
+ : { lifetime: "session" };
442
445
  const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
443
446
  const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
444
447
  const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
@@ -530,6 +533,7 @@ export async function main(args, options) {
530
533
  }
531
534
  }
532
535
  const created = await createAgentSessionFromServices({
536
+ backgroundProcessPolicy,
533
537
  services,
534
538
  sessionManager,
535
539
  sessionStartEvent,
@@ -131,6 +131,11 @@ export class FooterComponent {
131
131
  const contextFull = theme.fg("footerContext", `context ${contextPercent}%`) +
132
132
  theme.fg("borderMuted", " / ") +
133
133
  theme.fg("footerWindow", windowDisplay);
134
+ const { input, cacheRead, cacheWrite } = this.session.getSessionStats().tokens;
135
+ const promptTokens = input + cacheRead + cacheWrite;
136
+ const cacheHitPercent = promptTokens > 0 && (cacheRead > 0 || cacheWrite > 0)
137
+ ? `${((cacheRead / promptTokens) * 100).toFixed(1)}%`
138
+ : undefined;
134
139
  const planMode = getCollaborationMode(this.session) === "plan";
135
140
  const planFull = theme.fg("warning", "Plan mode");
136
141
  const planCompact = theme.fg("warning", "Plan");
@@ -176,6 +181,9 @@ export class FooterComponent {
176
181
  };
177
182
  const tryAddField = (field) => trySetFields([...fields, field]);
178
183
  const tryUpgradeField = (key, text) => trySetFields(fields.map((field) => (field.key === key ? { ...field, text } : field)));
184
+ if (cacheHitPercent !== undefined) {
185
+ tryAddField({ key: "cache", order: 3, text: theme.fg("dim", `cache ${cacheHitPercent}`) });
186
+ }
179
187
  // Extension statuses are public UI state, so admit them before optional
180
188
  // detail upgrades and shortcut hints. Truncate only their own field.
181
189
  const extensionStatuses = this.footerData.getExtensionStatuses();
@@ -189,7 +197,7 @@ export class FooterComponent {
189
197
  if (statusText.length > 0 && availableWidth > 0) {
190
198
  tryAddField({
191
199
  key: "extensions",
192
- order: 4,
200
+ order: 5,
193
201
  text: truncateToWidth(theme.fg("dim", statusText), availableWidth, glyphs.ellipsis),
194
202
  });
195
203
  }
@@ -197,14 +205,17 @@ export class FooterComponent {
197
205
  tryAddField({ key: "thinking", order: 1, text: theme.fg("thinkingText", thinkingLevel) });
198
206
  tryAddField({
199
207
  key: "harness",
200
- order: 3,
208
+ order: 4,
201
209
  text: theme.fg("dim", `h:${harnessVersion}`) + evolutionSpinner,
202
210
  });
203
211
  tryUpgradeField("context", contextMedium);
204
212
  tryUpgradeField("thinking", theme.fg("thinkingText", `thinking ${thinkingLevel}`));
213
+ if (cacheHitPercent !== undefined) {
214
+ tryUpgradeField("cache", theme.fg("dim", `input cache hit ${cacheHitPercent}`));
215
+ }
205
216
  tryUpgradeField("harness", theme.fg("dim", `harness ${harnessVersion}`) + evolutionSpinner);
206
217
  tryUpgradeField("context", contextFull);
207
- tryAddField({ key: "hotkeys", order: 7, text: theme.fg("dim", "/hotkeys") });
218
+ tryAddField({ key: "hotkeys", order: 8, text: theme.fg("dim", "/hotkeys") });
208
219
  const getBindingHint = (action, label) => {
209
220
  try {
210
221
  const binding = keyText(action);
@@ -216,11 +227,11 @@ export class FooterComponent {
216
227
  };
217
228
  const thinkingHint = getBindingHint("app.thinking.cycle", "thinking");
218
229
  if (thinkingHint) {
219
- tryAddField({ key: "thinkingHint", order: 5, text: thinkingHint });
230
+ tryAddField({ key: "thinkingHint", order: 6, text: thinkingHint });
220
231
  }
221
232
  const modelHint = getBindingHint("app.model.cycleForward", "model");
222
233
  if (modelHint) {
223
- tryAddField({ key: "modelHint", order: 6, text: modelHint });
234
+ tryAddField({ key: "modelHint", order: 7, text: modelHint });
224
235
  }
225
236
  return [renderFields(fields)];
226
237
  }
@@ -7,6 +7,7 @@ export declare class ToolGroupComponent implements Component {
7
7
  private pathOrigin;
8
8
  private reusesLeadingGap;
9
9
  private settled;
10
+ private cachedRender?;
10
11
  constructor(outputPad?: number, pathOrigin?: DiffexPathOrigin, options?: {
11
12
  reuseLeadingGap?: boolean;
12
13
  });