@iinm/plain-agent 1.9.2 → 1.9.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.
package/README.md CHANGED
@@ -53,7 +53,7 @@ A lightweight, capable coding agent for the terminal.
53
53
  </details>
54
54
 
55
55
 
56
- - **Approval rules & path validation** — Auto-approve tool uses by name and arguments using regex patterns ([config.predefined.json#autoApproval](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)); restrict file access to the working directory — git-ignored and untracked files require explicit approval ([src/toolInputValidator.mjs](https://github.com/iinm/plain-agent/blob/main/src/toolInputValidator.mjs)).
56
+ - **Approval rules & path validation** — Auto-approve tool uses by name and arguments using regex patterns ([config.predefined.json#autoApproval](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)); restrict file access to the working directory — git-ignored files require explicit approval ([src/toolInputValidator.mjs](https://github.com/iinm/plain-agent/blob/main/src/toolInputValidator.mjs)).
57
57
  - **Sandboxed execution** — Run agent commands in a Docker container with a read-only project root and no network; writable mode and allowlisted network destinations can be enabled as needed.
58
58
  - **Plain-text memory** — Task state is saved as Markdown files under `.plain-agent/memory/` for easy review.
59
59
  - **Extensible** — Define prompts and subagents in Markdown. Connect MCP servers.
@@ -62,7 +62,6 @@ A lightweight, capable coding agent for the terminal.
62
62
  ## Limitations
63
63
 
64
64
  - **Path validation only covers tool arguments** — Path validation restricts only paths explicitly passed as tool-use arguments; it cannot control file access inside arbitrary scripts. Always use sandboxed execution when allowing arbitrary script execution.
65
- - **No session persistence** — Sessions are not persisted. Start a fresh session and use memory files (`.plain-agent/memory/`) instead.
66
65
  - **Sequential subagent execution** — Subagents run one at a time rather than
67
66
  in parallel. The trade-off is full visibility: every step is streamed to
68
67
  your terminal so you can follow exactly what each subagent is doing.
@@ -338,6 +337,21 @@ plain cost
338
337
  plain cost --from 2026-04-01 --to 2026-04-30
339
338
  ```
340
339
 
340
+ Resume a previously interrupted interactive session. Sessions are
341
+ auto-saved to `.plain-agent/sessions/` and can be removed with `rm` when
342
+ no longer needed. Without an argument, the most recently updated session
343
+ is resumed. Use `--list` to see resumable sessions. Switching models is
344
+ not supported (`-m` is rejected).
345
+
346
+ ```sh
347
+ plain resume
348
+ ```
349
+
350
+ ```
351
+ plain resume --list
352
+ plain resume 2026-05-10-0803-a7k
353
+ ```
354
+
341
355
  Configure plain-agent for your project.
342
356
 
343
357
  ```
@@ -591,10 +605,14 @@ The agent searches for subagent definitions in the following directories:
591
605
 
592
606
  ```md
593
607
  ---
594
- description: Simplifies and refines code for clarity and maintainability
608
+ description: Fetches a web page and answers questions about its content
595
609
  ---
596
610
 
597
- You are a code simplifier. Your role is to refactor code while preserving its functionality.
611
+ You are a web content reader and analyzer. Given a URL and a question, you:
612
+
613
+ 1. Fetch the page content using `w3m -dump <URL>`.
614
+ 2. Read and understand the fetched content.
615
+ 3. Answer the user's question based on the content.
598
616
  ```
599
617
 
600
618
  ## Claude Code Plugin Support
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agent.d.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  PartialMessageContent,
10
10
  ProviderTokenUsage,
11
11
  } from "./model";
12
+ import type { SessionState } from "./sessionStore.mjs";
12
13
  import type { Tool, ToolUseApprover } from "./tool";
13
14
 
14
15
  export type Agent = {
@@ -18,10 +19,12 @@ export type Agent = {
18
19
  };
19
20
 
20
21
  export type AgentCommands = {
21
- dumpMessages: () => Promise<void>;
22
- loadMessages: () => Promise<void>;
23
22
  getCostSummary: () => CostSummary;
24
23
  pauseAutoApprove: () => void;
24
+ /** Subagent currently active for this session, or null. */
25
+ getActiveSubagent: () => { name: string } | null;
26
+ /** Wait for any pending session-state writes to flush to disk. */
27
+ flushSessionPersistence: () => Promise<void>;
25
28
  };
26
29
 
27
30
  type UserEventMap = {
@@ -49,4 +52,13 @@ export type AgentConfig = {
49
52
  toolUseApprover: ToolUseApprover;
50
53
  agentRoles: Map<string, AgentRole>;
51
54
  modelCostConfig?: CostConfig;
55
+ /** Metadata used when persisting session state. */
56
+ sessionMetadata: {
57
+ sessionId: string;
58
+ modelName: string;
59
+ workingDir: string;
60
+ startTime: Date;
61
+ };
62
+ /** When provided, the agent restores its state from this snapshot. */
63
+ initialState?: SessionState | null;
52
64
  };
package/src/agent.mjs CHANGED
@@ -7,11 +7,11 @@
7
7
  */
8
8
 
9
9
  import { EventEmitter } from "node:events";
10
- import fs from "node:fs/promises";
10
+ import { styleText } from "node:util";
11
11
  import { createAgentLoop } from "./agentLoop.mjs";
12
12
  import { createStateManager } from "./agentState.mjs";
13
13
  import { createCostTracker } from "./costTracker.mjs";
14
- import { MESSAGES_DUMP_FILE_PATH } from "./env.mjs";
14
+ import { SESSION_FILE_VERSION, saveSession } from "./sessionStore.mjs";
15
15
  import { createSubagentManager } from "./subagent.mjs";
16
16
  import { createToolExecutor } from "./toolExecutor.mjs";
17
17
  import {
@@ -32,6 +32,8 @@ export function createAgent({
32
32
  toolUseApprover,
33
33
  agentRoles,
34
34
  modelCostConfig,
35
+ sessionMetadata,
36
+ initialState,
35
37
  }) {
36
38
  /** @type {UserEventEmitter} */
37
39
  const userEventEmitter = new EventEmitter();
@@ -44,23 +46,27 @@ export function createAgent({
44
46
  costTracker.recordUsage(usage);
45
47
  });
46
48
 
47
- const stateManager = createStateManager(
48
- [
49
- {
50
- role: "system",
51
- content: [{ type: "text", text: prompt }],
52
- },
53
- ],
54
- {
55
- onMessagesAppended: (newMessages) => {
56
- const lastMessage = newMessages.at(-1);
57
- if (!lastMessage) {
58
- return;
59
- }
49
+ // Build the initial message list. When resuming, replace messages[0] with
50
+ // the freshly built system prompt (today/agent roles/skills may have
51
+ // changed) but keep the rest of the saved conversation verbatim.
52
+ /** @type {import("./model").SystemMessage} */
53
+ const systemMessage = {
54
+ role: "system",
55
+ content: [{ type: "text", text: prompt }],
56
+ };
57
+ const baseMessages = initialState?.messages?.length
58
+ ? [systemMessage, ...initialState.messages.slice(1)]
59
+ : [systemMessage];
60
+
61
+ const stateManager = createStateManager(baseMessages, {
62
+ onMessagesAppended: (newMessages) => {
63
+ const lastMessage = newMessages.at(-1);
64
+ if (lastMessage) {
60
65
  agentEventEmitter.emit("message", lastMessage);
61
- },
66
+ }
67
+ schedulePersist();
62
68
  },
63
- );
69
+ });
64
70
 
65
71
  const subagentManager = createSubagentManager(agentRoles, {
66
72
  onSubagentSwitched: (subagent) => {
@@ -68,6 +74,46 @@ export function createAgent({
68
74
  },
69
75
  });
70
76
 
77
+ // Restore the rest of the session state. Subagent restoration is silent
78
+ // (no event), since CLI listeners aren't attached yet — the CLI consults
79
+ // getActiveSubagent() at startup instead.
80
+ if (initialState) {
81
+ subagentManager.restoreState(initialState.subagentState);
82
+ toolUseApprover.restoreAllowedToolUseInSession(
83
+ initialState.allowedToolUseInSession,
84
+ );
85
+ costTracker.restoreUsageHistory(initialState.tokenUsageHistory);
86
+ }
87
+
88
+ /** @type {Promise<void>} */
89
+ let persistChain = Promise.resolve();
90
+ function schedulePersist() {
91
+ persistChain = persistChain.then(async () => {
92
+ try {
93
+ await saveSession({
94
+ version: SESSION_FILE_VERSION,
95
+ sessionId: sessionMetadata.sessionId,
96
+ modelName: sessionMetadata.modelName,
97
+ workingDir: sessionMetadata.workingDir,
98
+ startTime: sessionMetadata.startTime.toISOString(),
99
+ lastUpdatedAt: new Date().toISOString(),
100
+ messages: stateManager.getMessages(),
101
+ subagentState: subagentManager.getState(),
102
+ allowedToolUseInSession: toolUseApprover.getAllowedToolUseInSession(),
103
+ tokenUsageHistory: costTracker.getUsageHistory(),
104
+ });
105
+ } catch (err) {
106
+ const message = err instanceof Error ? err.message : String(err);
107
+ console.error(
108
+ styleText(
109
+ "yellow",
110
+ `Warning: failed to persist session state: ${message}`,
111
+ ),
112
+ );
113
+ }
114
+ });
115
+ }
116
+
71
117
  /**
72
118
  * @param {SwitchToSubagentInput} input
73
119
  */
@@ -130,42 +176,6 @@ export function createAgent({
130
176
  exclusiveToolNames: [switchToSubagentToolName, switchToMainAgentToolName],
131
177
  });
132
178
 
133
- async function dumpMessages() {
134
- const filePath = MESSAGES_DUMP_FILE_PATH;
135
- try {
136
- await fs.writeFile(
137
- filePath,
138
- JSON.stringify(stateManager.getMessages(), null, 2),
139
- );
140
- console.log(`Messages dumped to ${filePath}`);
141
- } catch (error) {
142
- const message = error instanceof Error ? error.message : String(error);
143
- console.error(`Error dumping messages: ${message}`);
144
- }
145
- }
146
-
147
- async function loadMessages() {
148
- const filePath = MESSAGES_DUMP_FILE_PATH;
149
- try {
150
- const data = await fs.readFile(filePath, "utf-8");
151
- const loadedMessages = JSON.parse(data);
152
- if (Array.isArray(loadedMessages)) {
153
- // Keep the system message (index 0) and replace the rest
154
- stateManager.setMessages([
155
- stateManager.getMessageAt(0),
156
- ...loadedMessages.slice(1),
157
- ]);
158
- console.log(`Messages loaded from ${filePath}`);
159
- } else {
160
- console.error("Error loading messages: Invalid format in file.");
161
- }
162
- } catch (error) {
163
- if (error instanceof Error) {
164
- console.error(`Error loading messages: ${error.message}`);
165
- }
166
- }
167
- }
168
-
169
179
  // Pause signal: set by Ctrl-C during agent execution, checked after each tool batch completes
170
180
  let paused = false;
171
181
  /** @type {import("./agentLoop.mjs").PauseSignal} */
@@ -193,12 +203,14 @@ export function createAgent({
193
203
  userEventEmitter,
194
204
  agentEventEmitter,
195
205
  agentCommands: {
196
- dumpMessages,
197
- loadMessages,
198
206
  getCostSummary: () => costTracker.calculateCost(),
199
207
  pauseAutoApprove: () => {
200
208
  paused = true;
201
209
  },
210
+ getActiveSubagent: () => subagentManager.getActiveSubagent(),
211
+ flushSessionPersistence: async () => {
212
+ await persistChain;
213
+ },
202
214
  },
203
215
  };
204
216
  }
package/src/cliArgs.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand} Subcommand
2
+ * @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand | ResumeSubcommand} Subcommand
3
3
  */
4
4
 
5
5
  /**
@@ -26,6 +26,13 @@
26
26
  * @typedef {{ type: 'cost', from: string | null, to: string | null }} CostSubcommand
27
27
  */
28
28
 
29
+ /**
30
+ * Resume a previously interrupted interactive session.
31
+ * - `sessionId === null` and `list === false`: resume the most recently updated session.
32
+ * - `list === true`: print the resumable sessions and exit.
33
+ * @typedef {{ type: 'resume', sessionId: string | null, list: boolean, config: string[] }} ResumeSubcommand
34
+ */
35
+
29
36
  /**
30
37
  * @typedef {Object} CliArgs
31
38
  * @property {Subcommand} subcommand - The subcommand to execute
@@ -110,6 +117,38 @@ export function parseCliArgs(argv) {
110
117
  };
111
118
  }
112
119
 
120
+ if (subcommandName === "resume") {
121
+ const resumeArgs = args.slice(1);
122
+ /** @type {string | null} */
123
+ let sessionId = null;
124
+ let list = false;
125
+ /** @type {string[]} */
126
+ const config = [];
127
+
128
+ for (let i = 0; i < resumeArgs.length; i++) {
129
+ const arg = resumeArgs[i];
130
+ if (arg === "--list") {
131
+ list = true;
132
+ } else if (arg === "-c" || arg === "--config") {
133
+ if (resumeArgs[i + 1]) {
134
+ config.push(resumeArgs[i + 1]);
135
+ i++;
136
+ }
137
+ } else if (arg === "-m" || arg === "--model") {
138
+ // Switching models on resume is not supported by design.
139
+ return {
140
+ subcommand: { type: "help" },
141
+ };
142
+ } else if (!arg.startsWith("-") && sessionId === null) {
143
+ sessionId = arg;
144
+ }
145
+ }
146
+
147
+ return {
148
+ subcommand: { type: "resume", sessionId, list, config },
149
+ };
150
+ }
151
+
113
152
  if (subcommandName === "cost") {
114
153
  const costArgs = args.slice(1);
115
154
  let from = null;
@@ -145,6 +184,7 @@ export function printHelp(exitCode = 0) {
145
184
  console.log(`
146
185
  Usage: plain [options]
147
186
  plain batch [options] <task>
187
+ plain resume [<sessionId>] [--list] [-c <file>]
148
188
  plain cost [--from YYYY-MM-DD] [--to YYYY-MM-DD]
149
189
  plain list-models
150
190
  plain install-claude-code-plugins
@@ -158,6 +198,11 @@ Subcommands:
158
198
  batch <task> Run in batch mode with the given task instruction.
159
199
  Config files are NOT auto-loaded in batch mode;
160
200
  use -c to specify config files explicitly.
201
+ resume Resume an interactive session that was
202
+ interrupted. With no sessionId, resumes the
203
+ most recently updated session. Use --list to
204
+ see resumable sessions. Switching models is
205
+ not supported (-m is rejected).
161
206
  cost Show aggregated token cost per day for a period.
162
207
  Defaults to the first day of the current month
163
208
  through today.
package/src/cliBatch.mjs CHANGED
@@ -71,6 +71,7 @@ export async function startBatchSession({
71
71
  });
72
72
  }
73
73
 
74
+ await agentCommands.flushSessionPersistence();
74
75
  await onStop();
75
76
  resolve();
76
77
  });
@@ -131,18 +131,6 @@ export function createCommandHandler({
131
131
  return "continue";
132
132
  }
133
133
 
134
- // /dump
135
- if (inputTrimmed.toLowerCase() === "/dump") {
136
- await agentCommands.dumpMessages();
137
- return "prompt";
138
- }
139
-
140
- // /load
141
- if (inputTrimmed.toLowerCase() === "/load") {
142
- await agentCommands.loadMessages();
143
- return "prompt";
144
- }
145
-
146
134
  // /cost
147
135
  if (inputTrimmed.toLowerCase() === "/cost") {
148
136
  const summary = agentCommands.getCostSummary();
@@ -32,8 +32,6 @@ export const SLASH_COMMANDS = [
32
32
  name: "/resume",
33
33
  description: "Resume conversation after an LLM provider error",
34
34
  },
35
- { name: "/dump", description: "Save current messages to a JSON file" },
36
- { name: "/load", description: "Load messages from a JSON file" },
37
35
  { name: "/cost", description: "Display session cost and token usage" },
38
36
  {
39
37
  name: "/compact",
@@ -112,7 +112,7 @@ export function startInteractiveSession({
112
112
  const state = {
113
113
  turn: true,
114
114
  multiLineBuffer: null,
115
- subagentName: "",
115
+ subagentName: agentCommands.getActiveSubagent()?.name ?? "",
116
116
  };
117
117
 
118
118
  /**
@@ -157,6 +157,7 @@ export function startInteractiveSession({
157
157
  console.log();
158
158
  console.log(formatCostSummary(summary));
159
159
  await persistUsage(summary, { sessionId, modelName, startTime });
160
+ await agentCommands.flushSessionPersistence();
160
161
  await onStop();
161
162
  process.exit(0);
162
163
  };
@@ -351,7 +352,7 @@ export function startInteractiveSession({
351
352
  process.stdout.write("\x1b[?2004h");
352
353
  }
353
354
 
354
- let currentCliPrompt = getCliPrompt();
355
+ let currentCliPrompt = getCliPrompt(state.subagentName);
355
356
  cli = readline.createInterface({
356
357
  input: paste.transform,
357
358
  output: process.stdout,
@@ -29,6 +29,8 @@
29
29
  * @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage
30
30
  * @property {() => CostSummary} calculateCost - Calculate cost summary
31
31
  * @property {() => boolean} hasUsage - Check if any usage recorded
32
+ * @property {() => ProviderTokenUsage[]} getUsageHistory - Get a snapshot of the raw usage history
33
+ * @property {(history: ProviderTokenUsage[]) => void} restoreUsageHistory - Replace the usage history (used when resuming a saved session)
32
34
  */
33
35
 
34
36
  /**
@@ -110,11 +112,38 @@ export function createCostTracker(costConfig) {
110
112
  return usageHistory.length > 0;
111
113
  }
112
114
 
115
+ /**
116
+ * Get a snapshot copy of the raw usage history.
117
+ * @returns {ProviderTokenUsage[]}
118
+ */
119
+ function getUsageHistory() {
120
+ return usageHistory.map((u) => u);
121
+ }
122
+
123
+ /**
124
+ * Replace the usage history. Used when resuming a saved session.
125
+ * @param {ProviderTokenUsage[]} history
126
+ */
127
+ function restoreUsageHistory(history) {
128
+ if (!Array.isArray(history)) {
129
+ throw new TypeError("history must be an array");
130
+ }
131
+ usageHistory.length = 0;
132
+ for (const usage of history) {
133
+ if (typeof usage !== "object" || usage === null) {
134
+ throw new TypeError("each usage entry must be a non-null object");
135
+ }
136
+ usageHistory.push(usage);
137
+ }
138
+ }
139
+
113
140
  return Object.freeze({
114
141
  recordUsage,
115
142
  getAggregatedUsage,
116
143
  calculateCost,
117
144
  hasUsage,
145
+ getUsageHistory,
146
+ restoreUsageHistory,
118
147
  });
119
148
  }
120
149
 
package/src/env.mjs CHANGED
@@ -30,15 +30,11 @@ export const AGENT_PROJECT_METADATA_DIR = ".plain-agent";
30
30
 
31
31
  export const AGENT_MEMORY_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "memory");
32
32
  export const AGENT_TMP_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "tmp");
33
+ export const SESSIONS_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "sessions");
33
34
 
34
35
  export const CLAUDE_CODE_PLUGIN_DIR = path.join(
35
36
  AGENT_PROJECT_METADATA_DIR,
36
37
  "claude-code-plugins",
37
38
  );
38
39
 
39
- export const MESSAGES_DUMP_FILE_PATH = path.join(
40
- AGENT_PROJECT_METADATA_DIR,
41
- "messages.json",
42
- );
43
-
44
40
  export const USER_NAME = process.env.USER || "unknown";
package/src/main.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * @import { Tool } from "./tool";
3
+ * @import { SessionState } from "./sessionStore.mjs";
3
4
  */
4
5
 
6
+ import { randomInt } from "node:crypto";
5
7
  import { styleText } from "node:util";
6
8
  import { createAgent } from "./agent.mjs";
7
9
  import {
@@ -19,6 +21,7 @@ import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
19
21
  import { setupMCPServer } from "./mcpIntegration.mjs";
20
22
  import { createModelCaller } from "./modelCaller.mjs";
21
23
  import { createPrompt } from "./prompt.mjs";
24
+ import { listSessions, loadSession } from "./sessionStore.mjs";
22
25
  import { createAskURLTool } from "./tools/askURL.mjs";
23
26
  import { createAskWebTool } from "./tools/askWeb.mjs";
24
27
  import { createCompactContextTool } from "./tools/compactContext.mjs";
@@ -70,19 +73,74 @@ if (cliArgs.subcommand.type === "cost") {
70
73
  }
71
74
  }
72
75
 
76
+ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
77
+ const sessions = await listSessions();
78
+ if (sessions.length === 0) {
79
+ console.log("No resumable sessions in .plain-agent/sessions/.");
80
+ process.exit(0);
81
+ }
82
+ console.log("Resumable sessions (most recently updated first):\n");
83
+ for (const s of sessions) {
84
+ console.log(
85
+ ` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)}, ${s.messageCount} messages)`,
86
+ );
87
+ if (s.workingDir !== process.cwd()) {
88
+ console.log(` workingDir: ${s.workingDir}`);
89
+ }
90
+ }
91
+ process.exit(0);
92
+ }
93
+
73
94
  (async () => {
74
- const startTime = new Date();
75
- const sessionId = [
76
- `${startTime.getFullYear()}-${`0${startTime.getMonth() + 1}`.slice(-2)}-${`0${startTime.getDate()}`.slice(-2)}`,
77
- `0${startTime.getHours()}`.slice(-2) +
78
- `0${startTime.getMinutes()}`.slice(-2),
79
- ].join("-");
95
+ /** @type {SessionState | null} */
96
+ let resumedState = null;
97
+
98
+ if (cliArgs.subcommand.type === "resume") {
99
+ const requestedId = cliArgs.subcommand.sessionId;
100
+ if (requestedId) {
101
+ resumedState = await loadSession(requestedId);
102
+ if (!resumedState) {
103
+ console.error(
104
+ styleText("red", `No saved session found for id: ${requestedId}`),
105
+ );
106
+ process.exit(1);
107
+ }
108
+ } else {
109
+ const sessions = await listSessions();
110
+ if (sessions.length === 0) {
111
+ console.error(
112
+ styleText(
113
+ "red",
114
+ "No resumable sessions found in .plain-agent/sessions/.",
115
+ ),
116
+ );
117
+ process.exit(1);
118
+ }
119
+ resumedState = await loadSession(sessions[0].sessionId);
120
+ if (!resumedState) {
121
+ console.error(
122
+ styleText(
123
+ "red",
124
+ `Failed to load latest session: ${sessions[0].sessionId}`,
125
+ ),
126
+ );
127
+ process.exit(1);
128
+ }
129
+ }
130
+ }
131
+
132
+ const startTime = resumedState
133
+ ? new Date(resumedState.startTime)
134
+ : new Date();
135
+ const sessionId = resumedState ? resumedState.sessionId : generateSessionId();
80
136
  const tmuxSessionId = `agent-${sessionId}`;
81
137
 
82
138
  const isBatchMode = cliArgs.subcommand.type === "batch";
139
+ /** @type {string[]} */
83
140
  const configFiles =
84
141
  cliArgs.subcommand.type === "batch" ||
85
- cliArgs.subcommand.type === "interactive"
142
+ cliArgs.subcommand.type === "interactive" ||
143
+ cliArgs.subcommand.type === "resume"
86
144
  ? cliArgs.subcommand.config
87
145
  : [];
88
146
 
@@ -109,6 +167,23 @@ if (cliArgs.subcommand.type === "cost") {
109
167
  } else {
110
168
  console.log(styleText("yellow", "\n📦 Sandbox: off"));
111
169
  }
170
+
171
+ if (resumedState) {
172
+ console.log(
173
+ styleText("green", `\n⏯ Resuming session: ${resumedState.sessionId}`),
174
+ );
175
+ console.log(
176
+ ` ⤷ ${resumedState.messages.length} messages, last updated ${formatLocalDateTime(resumedState.lastUpdatedAt)}`,
177
+ );
178
+ if (resumedState.workingDir !== process.cwd()) {
179
+ console.log(
180
+ styleText(
181
+ "yellow",
182
+ ` ⚠ workingDir differs (saved: ${resumedState.workingDir}, current: ${process.cwd()})`,
183
+ ),
184
+ );
185
+ }
186
+ }
112
187
  }
113
188
 
114
189
  /** @type {(() => Promise<void>)[]} */
@@ -156,7 +231,30 @@ if (cliArgs.subcommand.type === "cost") {
156
231
  cliArgs.subcommand.type === "interactive"
157
232
  ? cliArgs.subcommand.model
158
233
  : null;
159
- const modelNameWithVariant = modelFromArgs || modelFromConfig;
234
+ let modelNameWithVariant = modelFromArgs || modelFromConfig;
235
+
236
+ if (resumedState) {
237
+ // Switching models on resume is not supported. The model from the saved
238
+ // session always wins. If config disagrees, fail loudly.
239
+ if (
240
+ modelNameWithVariant &&
241
+ modelNameWithVariant !== resumedState.modelName
242
+ ) {
243
+ console.error(
244
+ styleText(
245
+ "red",
246
+ [
247
+ `Cannot resume session ${resumedState.sessionId}: model mismatch.`,
248
+ ` saved model: ${resumedState.modelName}`,
249
+ ` current model: ${modelNameWithVariant}`,
250
+ "Resume must use the same model the session was started with.",
251
+ ].join("\n"),
252
+ ),
253
+ );
254
+ process.exit(1);
255
+ }
256
+ modelNameWithVariant = resumedState.modelName;
257
+ }
160
258
 
161
259
  const pluginPaths = resolvePluginPaths(appConfig.claudeCodePlugins ?? []);
162
260
  const [prompts, agentRoles] = await Promise.all([
@@ -243,6 +341,13 @@ if (cliArgs.subcommand.type === "cost") {
243
341
  toolUseApprover,
244
342
  agentRoles,
245
343
  modelCostConfig: modelDef.cost,
344
+ sessionMetadata: {
345
+ sessionId,
346
+ modelName: modelNameWithVariant,
347
+ workingDir: process.cwd(),
348
+ startTime,
349
+ },
350
+ initialState: resumedState,
246
351
  });
247
352
 
248
353
  const sessionOptions = {
@@ -281,3 +386,41 @@ if (cliArgs.subcommand.type === "cost") {
281
386
  console.error(err);
282
387
  process.exit(1);
283
388
  });
389
+
390
+ /**
391
+ * Generate a session id of the form `YYYY-MM-DD-HHMM-<3 random base36 chars>`.
392
+ * The random suffix avoids collisions when multiple `plain` processes start
393
+ * within the same minute. `randomInt` is uniform over `[0, 36 ** 3)`, so
394
+ * each suffix character is unbiased.
395
+ *
396
+ * @param {Date} [now]
397
+ * @returns {string}
398
+ */
399
+ function generateSessionId(now = new Date()) {
400
+ const date = [
401
+ `${now.getFullYear()}-${`0${now.getMonth() + 1}`.slice(-2)}-${`0${now.getDate()}`.slice(-2)}`,
402
+ `0${now.getHours()}`.slice(-2) + `0${now.getMinutes()}`.slice(-2),
403
+ ].join("-");
404
+ const suffix = randomInt(36 ** 3)
405
+ .toString(36)
406
+ .padStart(3, "0");
407
+ return `${date}-${suffix}`;
408
+ }
409
+
410
+ /**
411
+ * Format an ISO 8601 timestamp as `YYYY-MM-DD HH:MM:SS` in the local timezone.
412
+ *
413
+ * @param {string} iso
414
+ * @returns {string}
415
+ */
416
+ function formatLocalDateTime(iso) {
417
+ const d = new Date(iso);
418
+ if (Number.isNaN(d.getTime())) return iso;
419
+ const y = d.getFullYear();
420
+ const mo = `${d.getMonth() + 1}`.padStart(2, "0");
421
+ const da = `${d.getDate()}`.padStart(2, "0");
422
+ const h = `${d.getHours()}`.padStart(2, "0");
423
+ const mi = `${d.getMinutes()}`.padStart(2, "0");
424
+ const s = `${d.getSeconds()}`.padStart(2, "0");
425
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
426
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @import { Message, ProviderTokenUsage } from "./model"
3
+ * @import { ToolUsePattern } from "./tool"
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { SESSIONS_DIR } from "./env.mjs";
9
+
10
+ /** Current on-disk format version. Bump on breaking changes. */
11
+ export const SESSION_FILE_VERSION = 1;
12
+
13
+ /**
14
+ * @typedef {Object} SubagentSerializedState
15
+ * @property {{name: string, goal: string, switchMessageIndex: number}[]} subagents
16
+ * @property {number} subagentCount
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} SessionState
21
+ * @property {number} version
22
+ * @property {string} sessionId
23
+ * @property {string} modelName
24
+ * @property {string} workingDir
25
+ * @property {string} startTime - ISO 8601
26
+ * @property {string} lastUpdatedAt - ISO 8601
27
+ * @property {Message[]} messages
28
+ * @property {SubagentSerializedState} subagentState
29
+ * @property {ToolUsePattern[]} allowedToolUseInSession
30
+ * @property {ProviderTokenUsage[]} tokenUsageHistory
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} SessionSummary
35
+ * @property {string} sessionId
36
+ * @property {string} modelName
37
+ * @property {string} workingDir
38
+ * @property {string} startTime
39
+ * @property {string} lastUpdatedAt
40
+ * @property {number} messageCount
41
+ */
42
+
43
+ /**
44
+ * Resolve the path to a session file.
45
+ * @param {string} sessionId
46
+ * @param {{ dir?: string }} [options]
47
+ */
48
+ export function sessionFilePath(sessionId, options = {}) {
49
+ const dir = options.dir ?? SESSIONS_DIR;
50
+ return path.join(dir, `${sessionId}.json`);
51
+ }
52
+
53
+ /**
54
+ * Persist a session state atomically.
55
+ *
56
+ * Writes to a process-unique temp file in the same directory, then renames
57
+ * it over the target path. Same-directory rename is atomic on POSIX, so a
58
+ * crash during write leaves either the previous file or the new one — never
59
+ * a half-written file.
60
+ *
61
+ * @param {SessionState} state
62
+ * @param {{ dir?: string }} [options]
63
+ * @returns {Promise<void>}
64
+ */
65
+ export async function saveSession(state, options = {}) {
66
+ const dir = options.dir ?? SESSIONS_DIR;
67
+ await fs.mkdir(dir, { recursive: true });
68
+ const target = path.join(dir, `${state.sessionId}.json`);
69
+ const tmp = `${target}.tmp.${process.pid}`;
70
+ const json = JSON.stringify(state, null, 2);
71
+ await fs.writeFile(tmp, json, "utf8");
72
+ await fs.rename(tmp, target);
73
+ }
74
+
75
+ /**
76
+ * Load a session by id. Returns null when the file does not exist.
77
+ * Throws on parse errors or unsupported versions.
78
+ *
79
+ * @param {string} sessionId
80
+ * @param {{ dir?: string }} [options]
81
+ * @returns {Promise<SessionState | null>}
82
+ */
83
+ export async function loadSession(sessionId, options = {}) {
84
+ const dir = options.dir ?? SESSIONS_DIR;
85
+ const target = path.join(dir, `${sessionId}.json`);
86
+ /** @type {string} */
87
+ let raw;
88
+ try {
89
+ raw = await fs.readFile(target, "utf8");
90
+ } catch (err) {
91
+ if (
92
+ err instanceof Error &&
93
+ /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
94
+ ) {
95
+ return null;
96
+ }
97
+ throw err;
98
+ }
99
+
100
+ const parsed = JSON.parse(raw);
101
+ if (
102
+ typeof parsed !== "object" ||
103
+ parsed === null ||
104
+ typeof parsed.version !== "number"
105
+ ) {
106
+ throw new Error(`Invalid session file: ${target}`);
107
+ }
108
+ if (parsed.version !== SESSION_FILE_VERSION) {
109
+ throw new Error(
110
+ `Unsupported session file version ${parsed.version} at ${target} (expected ${SESSION_FILE_VERSION})`,
111
+ );
112
+ }
113
+ return /** @type {SessionState} */ (parsed);
114
+ }
115
+
116
+ /**
117
+ * List sessions in the sessions directory, sorted by lastUpdatedAt descending.
118
+ * Malformed files are silently skipped.
119
+ *
120
+ * @param {{ dir?: string }} [options]
121
+ * @returns {Promise<SessionSummary[]>}
122
+ */
123
+ export async function listSessions(options = {}) {
124
+ const dir = options.dir ?? SESSIONS_DIR;
125
+ /** @type {string[]} */
126
+ let entries;
127
+ try {
128
+ entries = await fs.readdir(dir);
129
+ } catch (err) {
130
+ if (
131
+ err instanceof Error &&
132
+ /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
133
+ ) {
134
+ return [];
135
+ }
136
+ throw err;
137
+ }
138
+
139
+ /** @type {SessionSummary[]} */
140
+ const summaries = [];
141
+ for (const name of entries) {
142
+ if (!name.endsWith(".json")) continue;
143
+ if (name.includes(".tmp.")) continue;
144
+ const sessionId = name.slice(0, -".json".length);
145
+ try {
146
+ const state = await loadSession(sessionId, { dir });
147
+ if (!state) continue;
148
+ summaries.push({
149
+ sessionId: state.sessionId,
150
+ modelName: state.modelName,
151
+ workingDir: state.workingDir,
152
+ startTime: state.startTime,
153
+ lastUpdatedAt: state.lastUpdatedAt,
154
+ messageCount: state.messages.length,
155
+ });
156
+ } catch {
157
+ // Skip malformed or version-mismatched files so a single bad file
158
+ // doesn't break listing.
159
+ }
160
+ }
161
+
162
+ summaries.sort((a, b) => b.lastUpdatedAt.localeCompare(a.lastUpdatedAt));
163
+ return summaries;
164
+ }
package/src/subagent.mjs CHANGED
@@ -256,10 +256,66 @@ export function createSubagentManager(agentRoles, handlers) {
256
256
  return subagents.length > 0;
257
257
  }
258
258
 
259
+ /**
260
+ * Get the most recently activated subagent, or null if none is active.
261
+ * @returns {{name: string} | null}
262
+ */
263
+ function getActiveSubagent() {
264
+ const top = subagents.at(-1);
265
+ return top ? { name: top.name } : null;
266
+ }
267
+
268
+ /**
269
+ * @typedef {Object} SubagentSerializedState
270
+ * @property {{name: string, goal: string, switchMessageIndex: number}[]} subagents
271
+ * @property {number} subagentCount
272
+ */
273
+
274
+ /**
275
+ * Snapshot the subagent stack for persistence.
276
+ * @returns {SubagentSerializedState}
277
+ */
278
+ function getState() {
279
+ return {
280
+ subagents: subagents.map((s) => ({ ...s })),
281
+ subagentCount,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Restore the subagent stack from a previously saved snapshot.
287
+ * Does NOT fire onSubagentSwitched; the caller is responsible for
288
+ * syncing any UI state (since listeners may not be attached yet).
289
+ * @param {SubagentSerializedState} state
290
+ */
291
+ function restoreState(state) {
292
+ if (typeof state !== "object" || state === null) {
293
+ throw new TypeError("state must be a non-null object");
294
+ }
295
+ if (!Array.isArray(state.subagents)) {
296
+ throw new TypeError("state.subagents must be an array");
297
+ }
298
+ if (typeof state.subagentCount !== "number") {
299
+ throw new TypeError("state.subagentCount must be a number");
300
+ }
301
+ subagents.length = 0;
302
+ for (const s of state.subagents) {
303
+ subagents.push({
304
+ name: s.name,
305
+ goal: s.goal,
306
+ switchMessageIndex: s.switchMessageIndex,
307
+ });
308
+ }
309
+ subagentCount = state.subagentCount;
310
+ }
311
+
259
312
  return {
260
313
  switchToSubagent,
261
314
  switchToMainAgent,
262
315
  processToolResults,
263
316
  isSubagentActive,
317
+ getActiveSubagent,
318
+ getState,
319
+ restoreState,
264
320
  };
265
321
  }
package/src/tool.d.ts CHANGED
@@ -59,6 +59,8 @@ export type ToolUseApprover = {
59
59
  isAllowedToolUse: (toolUse: MessageContentToolUse) => ToolUseDecision;
60
60
  allowToolUse: (toolUse: MessageContentToolUse) => void;
61
61
  resetApprovalCount: () => void;
62
+ getAllowedToolUseInSession: () => ToolUsePattern[];
63
+ restoreAllowedToolUseInSession: (patterns: ToolUsePattern[]) => void;
62
64
  };
63
65
 
64
66
  export type ToolUsePattern = {
@@ -91,9 +91,34 @@ export function createToolUseApprover({
91
91
  });
92
92
  }
93
93
 
94
+ /**
95
+ * Snapshot the tool-use patterns the user explicitly allowed during this
96
+ * session. Used to persist resumable session state.
97
+ * @returns {ToolUsePattern[]}
98
+ */
99
+ function getAllowedToolUseInSession() {
100
+ return state.allowedToolUseInSession.map((p) => ({ ...p }));
101
+ }
102
+
103
+ /**
104
+ * Replace the in-session allow-list with a previously saved snapshot.
105
+ * @param {ToolUsePattern[]} patterns
106
+ */
107
+ function restoreAllowedToolUseInSession(patterns) {
108
+ if (!Array.isArray(patterns)) {
109
+ throw new TypeError("patterns must be an array");
110
+ }
111
+ state.allowedToolUseInSession.length = 0;
112
+ for (const p of patterns) {
113
+ state.allowedToolUseInSession.push({ ...p });
114
+ }
115
+ }
116
+
94
117
  return {
95
118
  isAllowedToolUse,
96
119
  allowToolUse,
97
120
  resetApprovalCount,
121
+ getAllowedToolUseInSession,
122
+ restoreAllowedToolUseInSession,
98
123
  };
99
124
  }
@@ -84,6 +84,7 @@ export function createExecCommandTool(config) {
84
84
  PWD: process.env.PWD,
85
85
  PATH: process.env.PATH,
86
86
  HOME: process.env.HOME,
87
+ LANG: process.env.LANG,
87
88
  },
88
89
  timeout: 5 * 60 * 1000,
89
90
  },
@@ -40,14 +40,11 @@ inserted content
40
40
  prepended content
41
41
  @@@ ${nonce}
42
42
 
43
- - Line numbers are 1-indexed and refer to the original file;
44
- "{start}-{end}" is inclusive.
45
- - Hashes are 2-hex-char digests of each line's full content as shown
46
- by read_file (e.g. "a3"). They verify the LLM is targeting the
47
- correct lines; on mismatch, re-read the file with read_file.
48
- - "{N}:{afterHash}+" inserts after line N; "0+" prepends (no hash
49
- needed for line 0). "{lastLine}:{hash}+" appends.
50
- - Empty body deletes the range.
43
+ - The nonce "${nonce}" is constant; always use the exact value shown above.
44
+ - Line numbers are 1-indexed and refer to the original file; "{start}-{end}" is inclusive.
45
+ - Hashes are 2-character hex hashes of each line's full content as shown by read_file (e.g. "a3").
46
+ - "{N}:{afterHash}+" inserts after line N; "0+" prepends (no hash needed). "{lastLine}:{hash}+" appends.
47
+ - An empty body deletes the range.
51
48
  `.trim(),
52
49
  type: "string",
53
50
  },