@agent-link/agent 0.1.289 → 0.1.292

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.
@@ -22,7 +22,7 @@ import { listBrainSessions, loadBrainChatEntries, sendBrainChatMessage, getBrain
22
22
  import { listProviders, saveProvider, deleteProvider, getDefaultProviderId, setDefaultProviderId, getBackendTypeForProvider } from './providers.js';
23
23
  import { createTunnelHandler } from './tunnel.js';
24
24
  import { createTerminalManager } from './terminal.js';
25
- import { existsSync, mkdirSync } from 'fs';
25
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
26
26
  const require = createRequire(import.meta.url);
27
27
  const pkg = require('../package.json');
28
28
  const TEAM_SESSIONS_DIR = path.join(CONFIG_DIR, 'team-sessions');
@@ -33,7 +33,7 @@ import { claudeCapabilities } from './backends/types.js';
33
33
  import { searchSessions } from './search-sessions.js';
34
34
  import { formatSessionExport, isSessionExportFormat } from './session-export.js';
35
35
  import { listMemoryFiles, updateMemoryFile, deleteMemoryFile } from './memory.js';
36
- import { decodeKey, parseMessage, encryptAndSend, e2eEncrypt, e2eDecrypt, isE2EEncrypted } from './encryption.js';
36
+ import { decodeKey, parseMessage, encryptAndSend, encryptAndSendConfirmed, e2eEncrypt, e2eDecrypt, isE2EEncrypted } from './encryption.js';
37
37
  import { setTeamSendFn, setTeamClaudeFns, getActiveTeam, serializeTeam } from './team.js';
38
38
  import { refreshEntraToken, isTokenExpired } from './entra.js';
39
39
  import { initEntraTimers, startEntraRetryTimer, stopEntraRetryTimer, scheduleEntraRefresh, cancelEntraRefresh, } from './agent-entra-timer.js';
@@ -57,6 +57,8 @@ const state = {
57
57
  workDir: process.cwd(),
58
58
  config: null,
59
59
  entraToken: null,
60
+ acceptingWork: true,
61
+ relayReady: false,
60
62
  };
61
63
  let unsubscribeSend = null;
62
64
  let unsubscribeCopilotSend = null;
@@ -147,6 +149,8 @@ export function connect(config) {
147
149
  state.workDir = config.dir;
148
150
  state.config = config;
149
151
  state.shouldReconnect = true;
152
+ state.acceptingWork = true;
153
+ state.relayReady = false;
150
154
  setBackendType(config.agent ?? 'claude');
151
155
  // Initialize E2E encryption key
152
156
  const ignoreConfig = !!process.env.AGENTLINK_NO_STATE;
@@ -309,6 +313,7 @@ function doConnect(config, onRegistered, onError) {
309
313
  settled = true;
310
314
  onRegistered(state.sessionId);
311
315
  }
316
+ state.relayReady = state.config?.entra !== true;
312
317
  // Always check for session ID changes and update runtime state
313
318
  if (previousSessionId && previousSessionId !== newSessionId) {
314
319
  console.warn(`[AgentLink] Session ID changed: ${previousSessionId} → ${newSessionId}`);
@@ -421,6 +426,7 @@ function doConnect(config, onRegistered, onError) {
421
426
  }
422
427
  });
423
428
  ws.on('close', () => {
429
+ state.relayReady = false;
424
430
  stopHeartbeat();
425
431
  console.log('[AgentLink] Disconnected from server');
426
432
  if (state.shouldReconnect) {
@@ -518,6 +524,46 @@ export function send(msg) {
518
524
  console.warn(`[AgentLink] Cannot send ${msg.type}: WebSocket not open (readyState=${state.ws?.readyState})`);
519
525
  }
520
526
  }
527
+ export async function sendAndFlush(msg, timeoutMs = 1500) {
528
+ await sendQueue;
529
+ const ws = state.ws;
530
+ if (!ws || ws.readyState !== WebSocket.OPEN)
531
+ return;
532
+ let payload = msg;
533
+ if (state.e2eKey && state.e2eNegotiated) {
534
+ payload = await e2eEncrypt(msg, state.e2eKey);
535
+ }
536
+ let timeout;
537
+ try {
538
+ await Promise.race([
539
+ encryptAndSendConfirmed(ws, payload, state.sessionKey),
540
+ new Promise((_, reject) => {
541
+ timeout = setTimeout(() => reject(new Error(`WebSocket send did not drain within ${timeoutMs}ms`)), timeoutMs);
542
+ timeout.unref();
543
+ }),
544
+ ]);
545
+ }
546
+ finally {
547
+ if (timeout)
548
+ clearTimeout(timeout);
549
+ }
550
+ }
551
+ export function beginShutdown() {
552
+ state.acceptingWork = false;
553
+ }
554
+ export function getConnectionStatus() {
555
+ return {
556
+ websocketConnected: state.ws?.readyState === WebSocket.OPEN,
557
+ relayReady: state.relayReady,
558
+ acceptingWork: state.acceptingWork,
559
+ sessionId: state.sessionId,
560
+ workDir: state.workDir,
561
+ backend: state.config?.agent ?? 'claude',
562
+ conversations: getConversations().size
563
+ + getCopilotConversations().size
564
+ + getCodexConversations().size,
565
+ };
566
+ }
521
567
  function buildWsUrl(config) {
522
568
  const base = config.server.replace(/\/$/, '');
523
569
  const params = new URLSearchParams({
@@ -576,13 +622,35 @@ const pendingActionItems = new Map();
576
622
  const pendingActionDrafts = new Map();
577
623
  function handleServerMessage(msg) {
578
624
  console.log(`[AgentLink] ← ${msg.type}`);
625
+ const shutdownAllowedMessages = new Set([
626
+ 'ping',
627
+ 'pong',
628
+ 'entra_auth_ok',
629
+ 'entra_auth_failed',
630
+ 'cancel_execution',
631
+ 'terminal_close',
632
+ 'tunnel_ws_close',
633
+ ]);
634
+ if (!state.acceptingWork && !shutdownAllowedMessages.has(msg.type)) {
635
+ send({
636
+ type: 'error',
637
+ code: 'agent_shutting_down',
638
+ message: 'Agent is shutting down and is not accepting new work.',
639
+ conversationId: msg.conversationId,
640
+ requestId: msg.requestId,
641
+ rejectedType: msg.type,
642
+ });
643
+ return;
644
+ }
579
645
  switch (msg.type) {
580
646
  case 'entra_auth_ok':
647
+ state.relayReady = true;
581
648
  console.log(`[AgentLink] Entra identity verified: ${msg.email}`);
582
649
  stopEntraRetryTimer();
583
650
  scheduleEntraRefresh();
584
651
  break;
585
652
  case 'entra_auth_failed': {
653
+ state.relayReady = false;
586
654
  const reason = msg.reason;
587
655
  console.error(`[AgentLink] Entra auth failed: ${reason}`);
588
656
  if (reason === 'token_expired') {
@@ -1610,9 +1678,13 @@ function handleServerMessage(msg) {
1610
1678
  }
1611
1679
  }
1612
1680
  else {
1613
- // Same backend (claude), existing logic
1681
+ // Same backend (claude): reconnect provider sync must not interrupt
1682
+ // an active Claude process.
1614
1683
  const conv = getConversation(conversationId);
1615
- if (conv) {
1684
+ if (conv?.turnActive) {
1685
+ conv.providerId = providerId;
1686
+ }
1687
+ else if (conv) {
1616
1688
  const result = restartConversation(conversationId, { providerId });
1617
1689
  if (result.wasTurnActive) {
1618
1690
  send({ type: 'execution_cancelled', conversationId });
@@ -1758,6 +1830,9 @@ function handleServerMessage(msg) {
1758
1830
  case 'retry_action_item':
1759
1831
  handleRetryActionItem(msg);
1760
1832
  break;
1833
+ case 'reopen_action_item':
1834
+ handleReopenActionItem(msg);
1835
+ break;
1761
1836
  case 'create_action_draft':
1762
1837
  handleCreateActionDraft(msg);
1763
1838
  break;
@@ -2495,6 +2570,41 @@ async function handleDismissActionItem(msg) {
2495
2570
  }
2496
2571
  }
2497
2572
  // ── Actions Feed handlers ──────────────────────────────────────────────
2573
+ // Resolve the on-disk artifact reference for an action item, if any. All
2574
+ // follow-through items write their deliverables to
2575
+ // {BRAIN_DATA_DIR}/reports/follow-through/artifacts/{action_item_id}/
2576
+ // We surface it as a structured field so the card can show a "View artifact"
2577
+ // link without the frontend having to parse file paths out of summary prose.
2578
+ // Returns { artifact_path, artifact_label, artifact_is_dir } or {} when none.
2579
+ function resolveArtifact(actionItemId) {
2580
+ try {
2581
+ if (!actionItemId)
2582
+ return {};
2583
+ const dir = path.join(BRAIN_DATA_DIR, 'reports', 'follow-through', 'artifacts', actionItemId);
2584
+ if (!existsSync(dir))
2585
+ return {};
2586
+ const entries = readdirSync(dir).filter(f => !f.startsWith('.'));
2587
+ if (entries.length === 0)
2588
+ return {};
2589
+ // Prefer a single primary document: if exactly one file, link straight to
2590
+ // it; otherwise link to the directory and let the user pick.
2591
+ const files = entries.filter(f => {
2592
+ try {
2593
+ return statSync(path.join(dir, f)).isFile();
2594
+ }
2595
+ catch {
2596
+ return false;
2597
+ }
2598
+ });
2599
+ if (files.length === 1) {
2600
+ return { artifact_path: path.join(dir, files[0]), artifact_label: files[0], artifact_is_dir: false };
2601
+ }
2602
+ return { artifact_path: dir, artifact_label: `${files.length || entries.length} files`, artifact_is_dir: true };
2603
+ }
2604
+ catch {
2605
+ return {};
2606
+ }
2607
+ }
2498
2608
  async function handleListActions() {
2499
2609
  try {
2500
2610
  const actions = await listActions();
@@ -2508,6 +2618,7 @@ async function handleListActions() {
2508
2618
  ...a,
2509
2619
  meeting_name: a.meeting_name ?? (a.recap_id ? meetingNameByRecapId.get(a.recap_id) : undefined),
2510
2620
  work_dir: a.work_dir ?? a.session_cwd ?? homeDir,
2621
+ ...resolveArtifact(a.action_item_id),
2511
2622
  }));
2512
2623
  console.log(`[AgentLink] → actions_list (${actionsWithWorkDir.length} items)`);
2513
2624
  send({ type: 'actions_list', actions: actionsWithWorkDir });
@@ -2571,6 +2682,36 @@ async function handleRetryActionItem(msg) {
2571
2682
  clearTimeout(timer);
2572
2683
  }
2573
2684
  }
2685
+ async function handleReopenActionItem(msg) {
2686
+ // Reopen reactivates a terminal item (completed/dropped -> ready) and must
2687
+ // re-index it in the dispatcher, so POST to BrainServer's REST endpoint
2688
+ // rather than a bare CLI set-status.
2689
+ const controller = new AbortController();
2690
+ const timer = setTimeout(() => controller.abort(), 15000);
2691
+ try {
2692
+ const res = await fetch(`${BRAIN_SERVER}/api/actions/${encodeURIComponent(msg.actionItemId)}/reopen`, {
2693
+ method: 'POST',
2694
+ headers: { 'Content-Type': 'application/json' },
2695
+ body: JSON.stringify(msg.reopenedFrom ? { reopened_from: msg.reopenedFrom } : {}),
2696
+ signal: controller.signal,
2697
+ });
2698
+ if (!res.ok) {
2699
+ // Forward the HTTP status so the client can treat a 409 (item already
2700
+ // reopened / no longer terminal) as "already reopened", not an error.
2701
+ send({ type: 'action_item_reopened', actionItemId: msg.actionItemId, status: res.status, error: `reopen failed: HTTP ${res.status}` });
2702
+ return;
2703
+ }
2704
+ console.log(`[AgentLink] → action_item_reopened (${msg.actionItemId})`);
2705
+ send({ type: 'action_item_reopened', actionItemId: msg.actionItemId });
2706
+ }
2707
+ catch (err) {
2708
+ console.error(`[AgentLink] reopenActionItem failed for ${msg.actionItemId}:`, err);
2709
+ send({ type: 'action_item_reopened', actionItemId: msg.actionItemId, error: String(err) });
2710
+ }
2711
+ finally {
2712
+ clearTimeout(timer);
2713
+ }
2714
+ }
2574
2715
  async function handleCreateActionDraft(msg) {
2575
2716
  try {
2576
2717
  const draft = await createActionDraft();