@iinm/plain-agent 1.9.3 → 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 +21 -3
- package/package.json +1 -1
- package/src/agent.d.ts +14 -2
- package/src/agent.mjs +67 -55
- package/src/cliArgs.mjs +46 -1
- package/src/cliBatch.mjs +1 -0
- package/src/cliCommands.mjs +0 -12
- package/src/cliCompleter.mjs +0 -2
- package/src/cliInteractive.mjs +3 -2
- package/src/costTracker.mjs +29 -0
- package/src/env.mjs +1 -5
- package/src/main.mjs +151 -8
- package/src/sessionStore.mjs +164 -0
- package/src/subagent.mjs +56 -0
- package/src/tool.d.ts +2 -0
- package/src/toolUseApprover.mjs +25 -0
- package/src/tools/execCommand.mjs +1 -0
package/README.md
CHANGED
|
@@ -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:
|
|
608
|
+
description: Fetches a web page and answers questions about its content
|
|
595
609
|
---
|
|
596
610
|
|
|
597
|
-
You are a
|
|
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
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
|
|
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 {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
],
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
package/src/cliCommands.mjs
CHANGED
|
@@ -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();
|
package/src/cliCompleter.mjs
CHANGED
|
@@ -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",
|
package/src/cliInteractive.mjs
CHANGED
|
@@ -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,
|
package/src/costTracker.mjs
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/toolUseApprover.mjs
CHANGED
|
@@ -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
|
}
|