@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.
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Server } from './server/server.ts';
4
4
  import { Instance } from './project/instance.ts';
5
5
  import { Log } from './util/log.ts';
6
- import { Bus } from './bus/index.ts';
6
+ // Bus is used via createBusEventSubscription in event-handler.js
7
7
  import { Session } from './session/index.ts';
8
8
  import { SessionPrompt } from './session/prompt.ts';
9
9
  // EOL is reserved for future use
@@ -21,7 +21,9 @@ import { UI } from './cli/ui.ts';
21
21
  import {
22
22
  runContinuousServerMode,
23
23
  runContinuousDirectMode,
24
+ resolveResumeSession,
24
25
  } from './cli/continuous-mode.js';
26
+ import { createBusEventSubscription } from './cli/event-handler.js';
25
27
  import { createRequire } from 'module';
26
28
  import { readFileSync } from 'fs';
27
29
  import { dirname, join } from 'path';
@@ -285,6 +287,7 @@ async function runAgentMode(argv, request) {
285
287
  if (argv.server) {
286
288
  // SERVER MODE: Start server and communicate via HTTP
287
289
  await runServerMode(
290
+ argv,
288
291
  request,
289
292
  providerID,
290
293
  modelID,
@@ -295,6 +298,7 @@ async function runAgentMode(argv, request) {
295
298
  } else {
296
299
  // DIRECT MODE: Run everything in single process
297
300
  await runDirectMode(
301
+ argv,
298
302
  request,
299
303
  providerID,
300
304
  modelID,
@@ -382,6 +386,7 @@ async function runContinuousAgentMode(argv) {
382
386
  }
383
387
 
384
388
  async function runServerMode(
389
+ argv,
385
390
  request,
386
391
  providerID,
387
392
  modelID,
@@ -389,102 +394,52 @@ async function runServerMode(
389
394
  appendSystemMessage,
390
395
  jsonStandard
391
396
  ) {
397
+ const compactJson = argv['compact-json'] === true;
398
+
392
399
  // Start server like OpenCode does
393
400
  const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
394
401
  let unsub = null;
395
402
 
396
403
  try {
397
- // Create a session
398
- const createRes = await fetch(
399
- `http://${server.hostname}:${server.port}/session`,
400
- {
401
- method: 'POST',
402
- headers: { 'Content-Type': 'application/json' },
403
- body: JSON.stringify({}),
404
- }
405
- );
406
- const session = await createRes.json();
407
- const sessionID = session.id;
404
+ // Check if we should resume an existing session
405
+ const resumeInfo = await resolveResumeSession(argv, compactJson);
406
+
407
+ let sessionID;
408
+
409
+ if (resumeInfo) {
410
+ // Use the resumed/forked session
411
+ sessionID = resumeInfo.sessionID;
412
+ } else {
413
+ // Create a new session
414
+ const createRes = await fetch(
415
+ `http://${server.hostname}:${server.port}/session`,
416
+ {
417
+ method: 'POST',
418
+ headers: { 'Content-Type': 'application/json' },
419
+ body: JSON.stringify({}),
420
+ }
421
+ );
422
+ const session = await createRes.json();
423
+ sessionID = session.id;
408
424
 
409
- if (!sessionID) {
410
- throw new Error('Failed to create session');
425
+ if (!sessionID) {
426
+ throw new Error('Failed to create session');
427
+ }
411
428
  }
412
429
 
413
430
  // Create event handler for the selected JSON standard
414
431
  const eventHandler = createEventHandler(jsonStandard, sessionID);
415
432
 
416
433
  // Subscribe to all bus events and output in selected format
417
- const eventPromise = new Promise((resolve) => {
418
- unsub = Bus.subscribeAll((event) => {
419
- // Output events in selected JSON format
420
- if (event.type === 'message.part.updated') {
421
- const part = event.properties.part;
422
- if (part.sessionID !== sessionID) {
423
- return;
424
- }
425
-
426
- // Output different event types
427
- if (part.type === 'step-start') {
428
- eventHandler.output({
429
- type: 'step_start',
430
- timestamp: Date.now(),
431
- sessionID,
432
- part,
433
- });
434
- }
435
-
436
- if (part.type === 'step-finish') {
437
- eventHandler.output({
438
- type: 'step_finish',
439
- timestamp: Date.now(),
440
- sessionID,
441
- part,
442
- });
443
- }
444
-
445
- if (part.type === 'text' && part.time?.end) {
446
- eventHandler.output({
447
- type: 'text',
448
- timestamp: Date.now(),
449
- sessionID,
450
- part,
451
- });
452
- }
453
-
454
- if (part.type === 'tool' && part.state.status === 'completed') {
455
- eventHandler.output({
456
- type: 'tool_use',
457
- timestamp: Date.now(),
458
- sessionID,
459
- part,
460
- });
461
- }
462
- }
463
-
464
- // Handle session idle to know when to stop
465
- if (
466
- event.type === 'session.idle' &&
467
- event.properties.sessionID === sessionID
468
- ) {
469
- resolve();
470
- }
471
-
472
- // Handle errors
473
- if (event.type === 'session.error') {
474
- const props = event.properties;
475
- if (props.sessionID !== sessionID || !props.error) {
476
- return;
477
- }
434
+ const { unsub: eventUnsub, idlePromise: eventPromise } =
435
+ createBusEventSubscription({
436
+ sessionID,
437
+ eventHandler,
438
+ onError: () => {
478
439
  hasError = true;
479
- eventHandler.output({
480
- type: 'error',
481
- timestamp: Date.now(),
482
- sessionID,
483
- error: props.error,
484
- });
485
- }
440
+ },
486
441
  });
487
- });
442
+ unsub = eventUnsub;
488
443
 
489
444
  // Send message to session with specified model (default: opencode/grok-code)
490
445
  const message = request.message || 'hi';
@@ -529,6 +484,7 @@ async function runServerMode(
529
484
  }
530
485
 
531
486
  async function runDirectMode(
487
+ argv,
532
488
  request,
533
489
  providerID,
534
490
  modelID,
@@ -536,91 +492,41 @@ async function runDirectMode(
536
492
  appendSystemMessage,
537
493
  jsonStandard
538
494
  ) {
495
+ const compactJson = argv['compact-json'] === true;
496
+
539
497
  // DIRECT MODE: Run in single process without server
540
498
  let unsub = null;
541
499
 
542
500
  try {
543
- // Create a session directly
544
- const session = await Session.createNext({
545
- directory: process.cwd(),
546
- });
547
- const sessionID = session.id;
501
+ // Check if we should resume an existing session
502
+ const resumeInfo = await resolveResumeSession(argv, compactJson);
503
+
504
+ let sessionID;
505
+
506
+ if (resumeInfo) {
507
+ // Use the resumed/forked session
508
+ sessionID = resumeInfo.sessionID;
509
+ } else {
510
+ // Create a new session directly
511
+ const session = await Session.createNext({
512
+ directory: process.cwd(),
513
+ });
514
+ sessionID = session.id;
515
+ }
548
516
 
549
517
  // Create event handler for the selected JSON standard
550
518
  const eventHandler = createEventHandler(jsonStandard, sessionID);
551
519
 
552
520
  // Subscribe to all bus events and output in selected format
553
- const eventPromise = new Promise((resolve) => {
554
- unsub = Bus.subscribeAll((event) => {
555
- // Output events in selected JSON format
556
- if (event.type === 'message.part.updated') {
557
- const part = event.properties.part;
558
- if (part.sessionID !== sessionID) {
559
- return;
560
- }
561
-
562
- // Output different event types
563
- if (part.type === 'step-start') {
564
- eventHandler.output({
565
- type: 'step_start',
566
- timestamp: Date.now(),
567
- sessionID,
568
- part,
569
- });
570
- }
571
-
572
- if (part.type === 'step-finish') {
573
- eventHandler.output({
574
- type: 'step_finish',
575
- timestamp: Date.now(),
576
- sessionID,
577
- part,
578
- });
579
- }
580
-
581
- if (part.type === 'text' && part.time?.end) {
582
- eventHandler.output({
583
- type: 'text',
584
- timestamp: Date.now(),
585
- sessionID,
586
- part,
587
- });
588
- }
589
-
590
- if (part.type === 'tool' && part.state.status === 'completed') {
591
- eventHandler.output({
592
- type: 'tool_use',
593
- timestamp: Date.now(),
594
- sessionID,
595
- part,
596
- });
597
- }
598
- }
599
-
600
- // Handle session idle to know when to stop
601
- if (
602
- event.type === 'session.idle' &&
603
- event.properties.sessionID === sessionID
604
- ) {
605
- resolve();
606
- }
607
-
608
- // Handle errors
609
- if (event.type === 'session.error') {
610
- const props = event.properties;
611
- if (props.sessionID !== sessionID || !props.error) {
612
- return;
613
- }
521
+ const { unsub: eventUnsub, idlePromise: eventPromise } =
522
+ createBusEventSubscription({
523
+ sessionID,
524
+ eventHandler,
525
+ onError: () => {
614
526
  hasError = true;
615
- eventHandler.output({
616
- type: 'error',
617
- timestamp: Date.now(),
618
- sessionID,
619
- error: props.error,
620
- });
621
- }
527
+ },
622
528
  });
623
- });
529
+ unsub = eventUnsub;
624
530
 
625
531
  // Send message to session directly
626
532
  const message = request.message || 'hi';
@@ -765,6 +671,25 @@ async function main() {
765
671
  description:
766
672
  'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
767
673
  default: false,
674
+ })
675
+ .option('resume', {
676
+ alias: 'r',
677
+ type: 'string',
678
+ description:
679
+ 'Resume a specific session by ID. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
680
+ })
681
+ .option('continue', {
682
+ alias: 'c',
683
+ type: 'boolean',
684
+ description:
685
+ 'Continue the most recent session. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
686
+ default: false,
687
+ })
688
+ .option('no-fork', {
689
+ type: 'boolean',
690
+ description:
691
+ 'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
692
+ default: false,
768
693
  }),
769
694
  handler: async (argv) => {
770
695
  const compactJson = argv['compact-json'] === true;
package/src/mcp/index.ts CHANGED
@@ -13,6 +13,18 @@ import { withTimeout } from '../util/timeout';
13
13
  export namespace MCP {
14
14
  const log = Log.create({ service: 'mcp' });
15
15
 
16
+ /** Built-in default timeout for MCP tool execution (2 minutes) */
17
+ export const BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT = 120000;
18
+
19
+ /** Built-in maximum timeout for MCP tool execution (10 minutes) */
20
+ export const BUILTIN_MAX_TOOL_CALL_TIMEOUT = 600000;
21
+
22
+ /** @deprecated Use BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT instead */
23
+ export const DEFAULT_TOOL_CALL_TIMEOUT = BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT;
24
+
25
+ /** @deprecated Use BUILTIN_MAX_TOOL_CALL_TIMEOUT instead */
26
+ export const MAX_TOOL_CALL_TIMEOUT = BUILTIN_MAX_TOOL_CALL_TIMEOUT;
27
+
16
28
  export const Failed = NamedError.create(
17
29
  'MCPFailed',
18
30
  z.object({
@@ -22,6 +34,22 @@ export namespace MCP {
22
34
 
23
35
  type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>;
24
36
 
37
+ /** Global timeout defaults from configuration */
38
+ export interface GlobalTimeoutDefaults {
39
+ /** Global default timeout for MCP tool calls */
40
+ defaultTimeout: number;
41
+ /** Global maximum timeout for MCP tool calls */
42
+ maxTimeout: number;
43
+ }
44
+
45
+ /** Timeout configuration for an MCP server */
46
+ export interface TimeoutConfig {
47
+ /** Default timeout for all tool calls from this server */
48
+ defaultTimeout: number;
49
+ /** Per-tool timeout overrides */
50
+ toolTimeouts: Record<string, number>;
51
+ }
52
+
25
53
  export const Status = z
26
54
  .discriminatedUnion('status', [
27
55
  z
@@ -59,6 +87,26 @@ export namespace MCP {
59
87
  const config = cfg.mcp ?? {};
60
88
  const clients: Record<string, Client> = {};
61
89
  const status: Record<string, Status> = {};
90
+ const timeoutConfigs: Record<string, TimeoutConfig> = {};
91
+
92
+ // Determine global timeout defaults from config and environment variables
93
+ const envDefaultTimeout = process.env.MCP_DEFAULT_TOOL_CALL_TIMEOUT
94
+ ? parseInt(process.env.MCP_DEFAULT_TOOL_CALL_TIMEOUT, 10)
95
+ : undefined;
96
+ const envMaxTimeout = process.env.MCP_MAX_TOOL_CALL_TIMEOUT
97
+ ? parseInt(process.env.MCP_MAX_TOOL_CALL_TIMEOUT, 10)
98
+ : undefined;
99
+
100
+ const globalDefaults: GlobalTimeoutDefaults = {
101
+ defaultTimeout:
102
+ cfg.mcp_defaults?.tool_call_timeout ??
103
+ envDefaultTimeout ??
104
+ BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT,
105
+ maxTimeout:
106
+ cfg.mcp_defaults?.max_tool_call_timeout ??
107
+ envMaxTimeout ??
108
+ BUILTIN_MAX_TOOL_CALL_TIMEOUT,
109
+ };
62
110
 
63
111
  await Promise.all(
64
112
  Object.entries(config).map(async ([key, mcp]) => {
@@ -67,6 +115,17 @@ export namespace MCP {
67
115
 
68
116
  status[key] = result.status;
69
117
 
118
+ // Store timeout configuration for this MCP server
119
+ // Per-server timeout overrides global default, but is capped at global max
120
+ const defaultTimeout = Math.min(
121
+ mcp.tool_call_timeout ?? globalDefaults.defaultTimeout,
122
+ globalDefaults.maxTimeout
123
+ );
124
+ timeoutConfigs[key] = {
125
+ defaultTimeout,
126
+ toolTimeouts: mcp.tool_timeouts ?? {},
127
+ };
128
+
70
129
  if (result.mcpClient) {
71
130
  clients[key] = result.mcpClient;
72
131
  }
@@ -75,6 +134,8 @@ export namespace MCP {
75
134
  return {
76
135
  status,
77
136
  clients,
137
+ timeoutConfigs,
138
+ globalDefaults,
78
139
  };
79
140
  },
80
141
  async (state) => {
@@ -310,4 +371,68 @@ export namespace MCP {
310
371
  }
311
372
  return result;
312
373
  }
374
+
375
+ /**
376
+ * Get the timeout configuration for all MCP servers
377
+ */
378
+ export async function getTimeoutConfigs(): Promise<
379
+ Record<string, TimeoutConfig>
380
+ > {
381
+ const s = await state();
382
+ return s.timeoutConfigs;
383
+ }
384
+
385
+ /**
386
+ * Get the global timeout defaults from configuration
387
+ */
388
+ export async function getGlobalDefaults(): Promise<GlobalTimeoutDefaults> {
389
+ const s = await state();
390
+ return s.globalDefaults;
391
+ }
392
+
393
+ /**
394
+ * Get the timeout for a specific MCP tool
395
+ * @param fullToolName The full tool name in format "serverName_toolName"
396
+ * @returns Timeout in milliseconds
397
+ */
398
+ export async function getToolTimeout(fullToolName: string): Promise<number> {
399
+ const s = await state();
400
+ const maxTimeout = s.globalDefaults.maxTimeout;
401
+
402
+ // Parse the full tool name to extract server name and tool name
403
+ // Format: serverName_toolName (where both are sanitized)
404
+ const underscoreIndex = fullToolName.indexOf('_');
405
+ if (underscoreIndex === -1) {
406
+ return s.globalDefaults.defaultTimeout;
407
+ }
408
+
409
+ const serverName = fullToolName.substring(0, underscoreIndex);
410
+ const toolName = fullToolName.substring(underscoreIndex + 1);
411
+
412
+ // Find the server config (need to handle sanitization)
413
+ const config = s.timeoutConfigs[serverName];
414
+ if (!config) {
415
+ // Try to find by iterating (in case of sanitization differences)
416
+ for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
417
+ const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '_');
418
+ if (sanitizedKey === serverName) {
419
+ // Check for per-tool timeout override
420
+ const perToolTimeout = cfg.toolTimeouts[toolName];
421
+ if (perToolTimeout !== undefined) {
422
+ return Math.min(perToolTimeout, maxTimeout);
423
+ }
424
+ return cfg.defaultTimeout;
425
+ }
426
+ }
427
+ return s.globalDefaults.defaultTimeout;
428
+ }
429
+
430
+ // Check for per-tool timeout override
431
+ const perToolTimeout = config.toolTimeouts[toolName];
432
+ if (perToolTimeout !== undefined) {
433
+ return Math.min(perToolTimeout, maxTimeout);
434
+ }
435
+
436
+ return config.defaultTimeout;
437
+ }
313
438
  }