@link-assistant/agent 0.6.3 → 0.8.1

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.
@@ -10,6 +10,7 @@ import { Session } from '../session/index.ts';
10
10
  import { SessionPrompt } from '../session/prompt.ts';
11
11
  import { createEventHandler } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
+ import { Log } from '../util/log.ts';
13
14
 
14
15
  // Shared error tracking
15
16
  let hasError = false;
@@ -42,6 +43,155 @@ function outputStatus(status, compact = false) {
42
43
  console.error(json);
43
44
  }
44
45
 
46
+ // Logger for resume operations
47
+ const log = Log.create({ service: 'resume' });
48
+
49
+ /**
50
+ * Resolve the session to use based on --resume, --continue, and --no-fork options.
51
+ * Returns the session ID to use, handling forking as needed.
52
+ * @param {object} argv - Command line arguments
53
+ * @param {boolean} compactJson - Whether to use compact JSON output
54
+ * @returns {Promise<{sessionID: string, wasResumed: boolean, wasForked: boolean} | null>} - Session info or null if new session should be created
55
+ * @export
56
+ */
57
+ export async function resolveResumeSession(argv, compactJson) {
58
+ const resumeSessionID = argv.resume;
59
+ const shouldContinue = argv.continue === true;
60
+ const noFork = argv['no-fork'] === true;
61
+
62
+ // If neither --resume nor --continue is specified, return null to create new session
63
+ if (!resumeSessionID && !shouldContinue) {
64
+ return null;
65
+ }
66
+
67
+ let targetSessionID = resumeSessionID;
68
+
69
+ // If --continue is specified, find the most recent session
70
+ if (shouldContinue && !targetSessionID) {
71
+ let mostRecentSession = null;
72
+ let mostRecentTime = 0;
73
+
74
+ for await (const session of Session.list()) {
75
+ // Skip child sessions (those with parentID) - find top-level sessions only
76
+ if (session.parentID) {
77
+ continue;
78
+ }
79
+
80
+ if (session.time.updated > mostRecentTime) {
81
+ mostRecentTime = session.time.updated;
82
+ mostRecentSession = session;
83
+ }
84
+ }
85
+
86
+ if (!mostRecentSession) {
87
+ outputStatus(
88
+ {
89
+ type: 'error',
90
+ errorType: 'SessionNotFound',
91
+ message:
92
+ 'No existing sessions found to continue. Start a new session first.',
93
+ },
94
+ compactJson
95
+ );
96
+ process.exit(1);
97
+ }
98
+
99
+ targetSessionID = mostRecentSession.id;
100
+ log.info(() => ({
101
+ message: 'Found most recent session to continue',
102
+ sessionID: targetSessionID,
103
+ title: mostRecentSession.title,
104
+ }));
105
+ }
106
+
107
+ // Verify the session exists
108
+ let existingSession;
109
+ try {
110
+ existingSession = await Session.get(targetSessionID);
111
+ } catch (_error) {
112
+ // Session.get throws an error when the session doesn't exist
113
+ outputStatus(
114
+ {
115
+ type: 'error',
116
+ errorType: 'SessionNotFound',
117
+ message: `Session not found: ${targetSessionID}`,
118
+ },
119
+ compactJson
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ if (!existingSession) {
125
+ outputStatus(
126
+ {
127
+ type: 'error',
128
+ errorType: 'SessionNotFound',
129
+ message: `Session not found: ${targetSessionID}`,
130
+ },
131
+ compactJson
132
+ );
133
+ process.exit(1);
134
+ }
135
+
136
+ log.info(() => ({
137
+ message: 'Resuming session',
138
+ sessionID: targetSessionID,
139
+ title: existingSession.title,
140
+ noFork,
141
+ }));
142
+
143
+ // If --no-fork is specified, continue in the same session
144
+ if (noFork) {
145
+ outputStatus(
146
+ {
147
+ type: 'status',
148
+ mode: 'resume',
149
+ message: `Continuing session without forking: ${targetSessionID}`,
150
+ sessionID: targetSessionID,
151
+ title: existingSession.title,
152
+ forked: false,
153
+ },
154
+ compactJson
155
+ );
156
+
157
+ return {
158
+ sessionID: targetSessionID,
159
+ wasResumed: true,
160
+ wasForked: false,
161
+ };
162
+ }
163
+
164
+ // Fork the session to a new UUID (default behavior)
165
+ const forkedSession = await Session.fork({
166
+ sessionID: targetSessionID,
167
+ });
168
+
169
+ outputStatus(
170
+ {
171
+ type: 'status',
172
+ mode: 'resume',
173
+ message: `Forked session ${targetSessionID} to new session: ${forkedSession.id}`,
174
+ originalSessionID: targetSessionID,
175
+ sessionID: forkedSession.id,
176
+ title: forkedSession.title,
177
+ forked: true,
178
+ },
179
+ compactJson
180
+ );
181
+
182
+ log.info(() => ({
183
+ message: 'Forked session',
184
+ originalSessionID: targetSessionID,
185
+ newSessionID: forkedSession.id,
186
+ }));
187
+
188
+ return {
189
+ sessionID: forkedSession.id,
190
+ wasResumed: true,
191
+ wasForked: true,
192
+ };
193
+ }
194
+
45
195
  /**
46
196
  * Run server mode with continuous stdin input
47
197
  * Keeps the session alive and processes messages as they arrive
@@ -64,20 +214,30 @@ export async function runContinuousServerMode(
64
214
  let stdinReader = null;
65
215
 
66
216
  try {
67
- // Create a session
68
- const createRes = await fetch(
69
- `http://${server.hostname}:${server.port}/session`,
70
- {
71
- method: 'POST',
72
- headers: { 'Content-Type': 'application/json' },
73
- body: JSON.stringify({}),
74
- }
75
- );
76
- const session = await createRes.json();
77
- const sessionID = session.id;
217
+ // Check if we should resume an existing session
218
+ const resumeInfo = await resolveResumeSession(argv, compactJson);
219
+
220
+ let sessionID;
221
+
222
+ if (resumeInfo) {
223
+ // Use the resumed/forked session
224
+ sessionID = resumeInfo.sessionID;
225
+ } else {
226
+ // Create a new session
227
+ const createRes = await fetch(
228
+ `http://${server.hostname}:${server.port}/session`,
229
+ {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({}),
233
+ }
234
+ );
235
+ const session = await createRes.json();
236
+ sessionID = session.id;
78
237
 
79
- if (!sessionID) {
80
- throw new Error('Failed to create session');
238
+ if (!sessionID) {
239
+ throw new Error('Failed to create session');
240
+ }
81
241
  }
82
242
 
83
243
  // Create event handler for the selected JSON standard
@@ -275,11 +435,21 @@ export async function runContinuousDirectMode(
275
435
  let stdinReader = null;
276
436
 
277
437
  try {
278
- // Create a session directly
279
- const session = await Session.createNext({
280
- directory: process.cwd(),
281
- });
282
- const sessionID = session.id;
438
+ // Check if we should resume an existing session
439
+ const resumeInfo = await resolveResumeSession(argv, compactJson);
440
+
441
+ let sessionID;
442
+
443
+ if (resumeInfo) {
444
+ // Use the resumed/forked session
445
+ sessionID = resumeInfo.sessionID;
446
+ } else {
447
+ // Create a new session directly
448
+ const session = await Session.createNext({
449
+ directory: process.cwd(),
450
+ });
451
+ sessionID = session.id;
452
+ }
283
453
 
284
454
  // Create event handler for the selected JSON standard
285
455
  const eventHandler = createEventHandler(jsonStandard, sessionID);
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared event handler logic for bus events.
3
+ * Used by both single-message mode (index.js) and continuous mode (continuous-mode.js).
4
+ */
5
+
6
+ import { Bus } from '../bus/index.ts';
7
+
8
+ /**
9
+ * Create a subscription for session bus events that outputs events in the selected JSON format.
10
+ * Returns an object with the unsubscribe function and a promise that resolves when session becomes idle.
11
+ *
12
+ * @param {object} options - Configuration options
13
+ * @param {string} options.sessionID - The session ID to filter events
14
+ * @param {object} options.eventHandler - The event handler (from createEventHandler)
15
+ * @param {function} options.onError - Callback when error occurs (sets hasError flag)
16
+ * @returns {{ unsub: function, idlePromise: Promise<void> }}
17
+ */
18
+ export function createBusEventSubscription({
19
+ sessionID,
20
+ eventHandler,
21
+ onError,
22
+ }) {
23
+ let idleResolve;
24
+ const idlePromise = new Promise((resolve) => {
25
+ idleResolve = resolve;
26
+ });
27
+
28
+ const unsub = Bus.subscribeAll((event) => {
29
+ // Output events in selected JSON format
30
+ if (event.type === 'message.part.updated') {
31
+ const part = event.properties.part;
32
+ if (part.sessionID !== sessionID) {
33
+ return;
34
+ }
35
+
36
+ // Output different event types
37
+ if (part.type === 'step-start') {
38
+ eventHandler.output({
39
+ type: 'step_start',
40
+ timestamp: Date.now(),
41
+ sessionID,
42
+ part,
43
+ });
44
+ }
45
+
46
+ if (part.type === 'step-finish') {
47
+ eventHandler.output({
48
+ type: 'step_finish',
49
+ timestamp: Date.now(),
50
+ sessionID,
51
+ part,
52
+ });
53
+ }
54
+
55
+ if (part.type === 'text' && part.time?.end) {
56
+ eventHandler.output({
57
+ type: 'text',
58
+ timestamp: Date.now(),
59
+ sessionID,
60
+ part,
61
+ });
62
+ }
63
+
64
+ if (part.type === 'tool' && part.state.status === 'completed') {
65
+ eventHandler.output({
66
+ type: 'tool_use',
67
+ timestamp: Date.now(),
68
+ sessionID,
69
+ part,
70
+ });
71
+ }
72
+ }
73
+
74
+ // Handle session idle to know when to stop
75
+ if (
76
+ event.type === 'session.idle' &&
77
+ event.properties.sessionID === sessionID
78
+ ) {
79
+ idleResolve();
80
+ }
81
+
82
+ // Handle errors
83
+ if (event.type === 'session.error') {
84
+ const props = event.properties;
85
+ if (props.sessionID !== sessionID || !props.error) {
86
+ return;
87
+ }
88
+ onError();
89
+ eventHandler.output({
90
+ type: 'error',
91
+ timestamp: Date.now(),
92
+ sessionID,
93
+ error: props.error,
94
+ });
95
+ }
96
+ });
97
+
98
+ return { unsub, idlePromise };
99
+ }
@@ -432,6 +432,20 @@ export namespace Config {
432
432
  .describe(
433
433
  'Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.'
434
434
  ),
435
+ tool_call_timeout: z
436
+ .number()
437
+ .int()
438
+ .positive()
439
+ .optional()
440
+ .describe(
441
+ 'Default timeout in ms for MCP tool execution. Defaults to 120000 (2 minutes) if not specified. Set per-tool overrides in tool_timeouts.'
442
+ ),
443
+ tool_timeouts: z
444
+ .record(z.string(), z.number().int().positive())
445
+ .optional()
446
+ .describe(
447
+ 'Per-tool timeout overrides in ms. Keys are tool names (e.g., "browser_run_code": 300000 for 5 minutes).'
448
+ ),
435
449
  })
436
450
  .strict()
437
451
  .meta({
@@ -458,6 +472,20 @@ export namespace Config {
458
472
  .describe(
459
473
  'Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.'
460
474
  ),
475
+ tool_call_timeout: z
476
+ .number()
477
+ .int()
478
+ .positive()
479
+ .optional()
480
+ .describe(
481
+ 'Default timeout in ms for MCP tool execution. Defaults to 120000 (2 minutes) if not specified. Set per-tool overrides in tool_timeouts.'
482
+ ),
483
+ tool_timeouts: z
484
+ .record(z.string(), z.number().int().positive())
485
+ .optional()
486
+ .describe(
487
+ 'Per-tool timeout overrides in ms. Keys are tool names (e.g., "browser_run_code": 300000 for 5 minutes).'
488
+ ),
461
489
  })
462
490
  .strict()
463
491
  .meta({
@@ -846,6 +874,29 @@ export namespace Config {
846
874
  .record(z.string(), Mcp)
847
875
  .optional()
848
876
  .describe('MCP (Model Context Protocol) server configurations'),
877
+ mcp_defaults: z
878
+ .object({
879
+ tool_call_timeout: z
880
+ .number()
881
+ .int()
882
+ .positive()
883
+ .optional()
884
+ .describe(
885
+ 'Global default timeout in ms for MCP tool execution. Defaults to 120000 (2 minutes). Can be overridden per-server or per-tool.'
886
+ ),
887
+ max_tool_call_timeout: z
888
+ .number()
889
+ .int()
890
+ .positive()
891
+ .optional()
892
+ .describe(
893
+ 'Maximum allowed timeout in ms for MCP tool execution. Defaults to 600000 (10 minutes). Tool timeouts will be capped at this value.'
894
+ ),
895
+ })
896
+ .optional()
897
+ .describe(
898
+ 'Global default settings for MCP tool call timeouts. Can be overridden at the server level.'
899
+ ),
849
900
  formatter: z
850
901
  .union([
851
902
  z.literal(false),