@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.
- package/README.md +40 -12
- 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/cliCost.mjs +21 -1
- package/src/cliFormatter.mjs +19 -12
- package/src/cliInteractive.mjs +3 -2
- package/src/config.d.ts +64 -4
- package/src/config.mjs +8 -8
- package/src/costTracker.mjs +29 -0
- package/src/env.mjs +1 -5
- package/src/main.mjs +229 -39
- 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/src/tools/webFetch.mjs +442 -0
- package/src/tools/webSearch.mjs +503 -0
- package/src/tools/askURL.mjs +0 -209
- package/src/tools/askWeb.mjs +0 -208
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,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 {
|
|
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
|
-
|
|
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([
|
|
@@ -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:
|
|
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 = {
|
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
|
}
|