@iinm/plain-agent 1.9.3 → 1.10.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.
@@ -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,8 +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";
22
- import { createAskURLTool } from "./tools/askURL.mjs";
23
- import { createAskWebTool } from "./tools/askWeb.mjs";
24
+ import { listSessions, loadSession } from "./sessionStore.mjs";
24
25
  import { createCompactContextTool } from "./tools/compactContext.mjs";
25
26
  import { createExecCommandTool } from "./tools/execCommand.mjs";
26
27
  import { createPatchFileTool } from "./tools/patchFile.mjs";
@@ -28,6 +29,8 @@ import { readFileTool } from "./tools/readFile.mjs";
28
29
  import { createSwitchToMainAgentTool } from "./tools/switchToMainAgent.mjs";
29
30
  import { createSwitchToSubagentTool } from "./tools/switchToSubagent.mjs";
30
31
  import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
32
+ import { createWebFetchTool } from "./tools/webFetch.mjs";
33
+ import { createWebSearchTool } from "./tools/webSearch.mjs";
31
34
  import { writeFileTool } from "./tools/writeFile.mjs";
32
35
  import { createToolUseApprover } from "./toolUseApprover.mjs";
33
36
 
@@ -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([
@@ -187,28 +285,6 @@ if (cliArgs.subcommand.type === "cost") {
187
285
  createSwitchToMainAgentTool(),
188
286
  ];
189
287
 
190
- if (appConfig.tools?.askWeb) {
191
- builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
192
- }
193
-
194
- if (appConfig.tools?.askURL) {
195
- builtinTools.push(createAskURLTool(appConfig.tools.askURL));
196
- }
197
-
198
- const toolUseApprover = createToolUseApprover({
199
- maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
200
- defaultAction: appConfig.autoApproval?.defaultAction || "ask",
201
- patterns: appConfig.autoApproval?.patterns || [],
202
- maskApprovalInput: (toolName, input) => {
203
- for (const tool of builtinTools) {
204
- if (tool.def.name === toolName && tool.maskApprovalInput) {
205
- return tool.maskApprovalInput(input);
206
- }
207
- }
208
- return input;
209
- },
210
- });
211
-
212
288
  const [modelName, modelVariant] = modelNameWithVariant.split("+");
213
289
  const modelDef = (appConfig.models ?? []).find(
214
290
  (entry) => entry.name === modelName && entry.variant === modelVariant,
@@ -230,19 +306,95 @@ if (cliArgs.subcommand.type === "cost") {
230
306
  );
231
307
  }
232
308
 
309
+ if (appConfig.tools?.webSearch) {
310
+ const webSearchConfig = appConfig.tools.webSearch;
311
+ if (webSearchConfig.provider === "command") {
312
+ const webSearchCallModel = createModelCaller({
313
+ ...modelDef,
314
+ platform: {
315
+ ...modelDef.platform,
316
+ ...platform,
317
+ },
318
+ });
319
+ builtinTools.push(
320
+ createWebSearchTool({
321
+ provider: "command",
322
+ command: webSearchConfig.command,
323
+ args: webSearchConfig.args,
324
+ timeoutMs: webSearchConfig.timeoutMs,
325
+ env: webSearchConfig.env,
326
+ modelCaller: webSearchCallModel,
327
+ maxLengthPerSearch: webSearchConfig.maxLengthPerSearch,
328
+ maxTotalLength: webSearchConfig.maxTotalLength,
329
+ }),
330
+ );
331
+ } else {
332
+ builtinTools.push(createWebSearchTool(webSearchConfig));
333
+ }
334
+ }
335
+
336
+ if (appConfig.tools?.webFetch) {
337
+ const webFetchConfig = appConfig.tools.webFetch;
338
+ if (webFetchConfig.provider === "command") {
339
+ const webFetchCallModel = createModelCaller({
340
+ ...modelDef,
341
+ platform: {
342
+ ...modelDef.platform,
343
+ ...platform,
344
+ },
345
+ });
346
+ builtinTools.push(
347
+ createWebFetchTool({
348
+ provider: "command",
349
+ command: webFetchConfig.command,
350
+ args: webFetchConfig.args,
351
+ timeoutMs: webFetchConfig.timeoutMs,
352
+ env: webFetchConfig.env,
353
+ modelCaller: webFetchCallModel,
354
+ maxLength: webFetchConfig.maxLength,
355
+ }),
356
+ );
357
+ } else {
358
+ builtinTools.push(createWebFetchTool(webFetchConfig));
359
+ }
360
+ }
361
+
362
+ const toolUseApprover = createToolUseApprover({
363
+ maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
364
+ defaultAction: appConfig.autoApproval?.defaultAction || "ask",
365
+ patterns: appConfig.autoApproval?.patterns || [],
366
+ maskApprovalInput: (toolName, input) => {
367
+ for (const tool of builtinTools) {
368
+ if (tool.def.name === toolName && tool.maskApprovalInput) {
369
+ return tool.maskApprovalInput(input);
370
+ }
371
+ }
372
+ return input;
373
+ },
374
+ });
375
+
376
+ const agentCallModel = createModelCaller({
377
+ ...modelDef,
378
+ platform: {
379
+ ...modelDef.platform,
380
+ ...platform,
381
+ },
382
+ });
383
+
233
384
  const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
234
- callModel: createModelCaller({
235
- ...modelDef,
236
- platform: {
237
- ...modelDef.platform,
238
- ...platform,
239
- },
240
- }),
385
+ callModel: agentCallModel,
241
386
  prompt,
242
387
  tools: [...builtinTools, ...mcpTools],
243
388
  toolUseApprover,
244
389
  agentRoles,
245
390
  modelCostConfig: modelDef.cost,
391
+ sessionMetadata: {
392
+ sessionId,
393
+ modelName: modelNameWithVariant,
394
+ workingDir: process.cwd(),
395
+ startTime,
396
+ },
397
+ initialState: resumedState,
246
398
  });
247
399
 
248
400
  const sessionOptions = {
@@ -281,3 +433,41 @@ if (cliArgs.subcommand.type === "cost") {
281
433
  console.error(err);
282
434
  process.exit(1);
283
435
  });
436
+
437
+ /**
438
+ * Generate a session id of the form `YYYY-MM-DD-HHMM-<3 random base36 chars>`.
439
+ * The random suffix avoids collisions when multiple `plain` processes start
440
+ * within the same minute. `randomInt` is uniform over `[0, 36 ** 3)`, so
441
+ * each suffix character is unbiased.
442
+ *
443
+ * @param {Date} [now]
444
+ * @returns {string}
445
+ */
446
+ function generateSessionId(now = new Date()) {
447
+ const date = [
448
+ `${now.getFullYear()}-${`0${now.getMonth() + 1}`.slice(-2)}-${`0${now.getDate()}`.slice(-2)}`,
449
+ `0${now.getHours()}`.slice(-2) + `0${now.getMinutes()}`.slice(-2),
450
+ ].join("-");
451
+ const suffix = randomInt(36 ** 3)
452
+ .toString(36)
453
+ .padStart(3, "0");
454
+ return `${date}-${suffix}`;
455
+ }
456
+
457
+ /**
458
+ * Format an ISO 8601 timestamp as `YYYY-MM-DD HH:MM:SS` in the local timezone.
459
+ *
460
+ * @param {string} iso
461
+ * @returns {string}
462
+ */
463
+ function formatLocalDateTime(iso) {
464
+ const d = new Date(iso);
465
+ if (Number.isNaN(d.getTime())) return iso;
466
+ const y = d.getFullYear();
467
+ const mo = `${d.getMonth() + 1}`.padStart(2, "0");
468
+ const da = `${d.getDate()}`.padStart(2, "0");
469
+ const h = `${d.getHours()}`.padStart(2, "0");
470
+ const mi = `${d.getMinutes()}`.padStart(2, "0");
471
+ const s = `${d.getSeconds()}`.padStart(2, "0");
472
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
473
+ }
@@ -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
  },