openvisio-agent 0.22.0 → 0.24.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/src/watch.mjs CHANGED
@@ -15,10 +15,15 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
15
15
  import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
16
16
  import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
17
17
  import { createMastraMemory } from './memory.mjs'
18
+ import { createContextService } from './context-service.mjs'
19
+ import { contextToolGuide } from './context-tools.mjs'
20
+ import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
18
21
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
19
22
  import { createMcpHttpClient } from './mcp-http.mjs'
20
23
  import { createCycleQueue } from './cycle-queue.mjs'
21
24
  import { createRunnerPool } from './runner-pool.mjs'
25
+ import { createActivityReporter } from './activity-reporter.mjs'
26
+ import { interruptedRuntimeResult, isTransportInterruption, transportRecoveryDelays } from './transport-recovery.mjs'
22
27
  import { mapConcurrent } from './concurrency.mjs'
23
28
  import { releaseAuthorizedPause } from './authorization-resume.mjs'
24
29
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
@@ -76,7 +81,7 @@ const REPLY_DISCIPLINE = [
76
81
  ' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
77
82
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
78
83
  ' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
79
- ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT repeat the same message. Do not send a generic pickup acknowledgement; activity shows that work is underway. For longer code work, you may send at most one concrete progress update after work has actually begun, but that update NEVER completes the cycle: keep using tools, then send one distinct verified result or real blocker. One final answer per question.',
84
+ ' • ACKNOWLEDGE TASKS, THEN DO THE WORK. Before starting an accepted task, publish a short, concrete plan using the native planning tool. The watcher sends one acknowledgement with your first 2-3 steps to the source thread. Do not post the same acknowledgement yourself. An acknowledgement or plan never completes the task: continue working, then give one distinct verified result or real blocker. Ordinary questions need only their answer; reconnects must not repeat the task acknowledgement.',
80
85
  ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Be willing to disagree or revise a conclusion when evidence changes. If you are unsure, investigate and distinguish a finding from an assumption.',
81
86
  ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
82
87
  ' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
@@ -137,6 +142,8 @@ const CODE_CHARTER = [
137
142
 
138
143
  const CODE_FULL = [
139
144
  'Handle the supplied verified ticket or source request. You decide how to investigate, which tools to use, what context to retain or compact, and when the work is ready for a final response.',
145
+ 'Before starting the task, use your native planning tool (update_plan, TodoWrite, or todowrite when advertised) to publish 2-3 short, concrete steps based on the supplied request. The watcher delivers this as your acknowledgement and brief plan. Keep working after the plan; only your final response ends the turn. Do not repeat the acknowledgement after a transport recovery.',
146
+ 'DELEGATION: Use native sub-agents for independent subtasks when they can shorten the work. Discover the runtime tools actually available: Codex spawn_agent/wait/send_input/close_agent, Claude Agent/Task, or OpenCode task. Give each child a bounded objective, relevant context, and explicit file ownership or a separate worktree. Work on another independent part while they run. Children inherit the current task authority; they cannot bypass a policy block, publish unrelated work, or answer the teammate separately. Review and integrate their results, close finished children, and deliver one parent response. Keep delegation bounded (normally at most three children); do not delegate trivial or dependent steps. If the runtime lacks delegation, continue locally and be clear about that limit.',
140
147
  'There is no required sequence of tools, code edits, ticket updates, or PR creation. Research, audits, explanations, coordination, and already-satisfied requests may finish without repository changes. Choose validation and delivery appropriate to the actual request.',
141
148
  'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
142
149
  'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
@@ -149,7 +156,7 @@ const CODE_FAST = [
149
156
  ' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
150
157
  ' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
151
158
  'Explore the request and its context. If local execution or edits are needed, use openvisio_request_work_session when advertised to continue in your own coding workspace. Do not turn an internal scheduling choice into a reassignment request.',
152
- 'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. Let the request determine how much detail the final response needs.',
159
+ 'For a non-code question, post ONE answer and stop. For code work, continue into your coding workspace, publish the short task plan for watcher acknowledgement, and keep working; then give one distinct final result with the evidence or a real blocker. Never repeat the acknowledgement or result.',
153
160
  ].join('\n')
154
161
 
155
162
  // ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
@@ -164,7 +171,7 @@ const INTRO = [
164
171
  const SWEEP = [
165
172
  'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
166
173
  'Use the available task/inbox tools. If get_marching_orders/poll_inbox are absent, use list_agents + list_projects + list_tasks to find tasks assigned to your agent identity, then:',
167
- ' 1. For every task assigned to YOU that you have NOT started: begin the work without a pickup acknowledgement. Use update_ticket while working and report only a verified result or concrete blocker through an actual source channel when one is supplied. Then do the work end-to-end. Skip tasks assigned to other agents.',
174
+ ' 1. For every task assigned to YOU that you have NOT started: publish a short task plan for watcher acknowledgement, then do the work end-to-end. Use update_ticket when appropriate and report a verified result or concrete blocker. Skip tasks assigned to other agents.',
168
175
  ' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
169
176
  'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
170
177
  ].join('\n')
@@ -330,16 +337,18 @@ export async function runWatch({ flags }) {
330
337
  }
331
338
 
332
339
  // Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
333
- export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false, workspaceAvailable = !!workdir }) {
340
+ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
334
341
  const canCode = !!workdir
335
342
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
336
343
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
337
344
  // its own ACP session and worktree, so cancellation, permissions and context
338
345
  // cannot bleed across concurrently assigned tickets.
339
- const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
346
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
340
347
  if (mastraRunner) return mastraRunner
341
348
  let child = null
342
349
  let privateMcpDir = ''
350
+ let contextEnvironment = {}
351
+ let disabledMcpTools = []
343
352
  // The model the CURRENT session was spawned with. runCycle can pass a different
344
353
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
345
354
  // session so the new model takes effect.
@@ -424,6 +433,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
424
433
  OPENVISIO_CODEX_IDENTIFIER: mcpHeaders['x-agent-identifier'],
425
434
  OPENVISIO_CAN_CODE: String(canCode),
426
435
  OPENVISIO_WORKSPACE_AVAILABLE: String(workspaceAvailable),
436
+ OPENVISIO_CODEX_DISABLED_TOOLS: JSON.stringify(disabledMcpTools),
437
+ ...contextEnvironment,
427
438
  },
428
439
  } } }, true)
429
440
  }
@@ -503,7 +514,14 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
503
514
  : ' [CONTEXT session — advertised tools, reading, search, and planning]'))
504
515
  }
505
516
 
506
- function runCycle(prompt, cycleModel, cycleOptions = {}) {
517
+ async function runCycle(prompt, cycleModel, cycleOptions = {}) {
518
+ contextEnvironment = await getContextEnvironment?.() || {}
519
+ const nextDisabled = [...(cycleOptions.disabledMcpTools || [])].sort()
520
+ if (JSON.stringify(nextDisabled) !== JSON.stringify(disabledMcpTools) && child) {
521
+ const previous = child; child = null
522
+ await stopModelProcess(previous)
523
+ }
524
+ disabledMcpTools = nextDisabled
507
525
  return new Promise((resolve) => {
508
526
  turnCycleId = cycleOptions.cycleId ?? null
509
527
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
@@ -591,6 +609,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
591
609
  const journal = runtimeJournal({ stateDir, agent: { identifier, slug, provider: agent }, secrets: [apiKey] }, dependencies.journal)
592
610
  const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, ...data })
593
611
  let cycleSequence = 0
612
+ const liveCycles = new Map()
594
613
  let mcpSequence = 0
595
614
  const cycleContext = new AsyncLocalStorage()
596
615
  emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
@@ -598,10 +617,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
598
617
  // Activity belongs to an individual cycle. Independent ticket workers must
599
618
  // not clear or overwrite each other's status targets.
600
619
  const replyStatusTargets = new Set()
601
- // Working/thinking/typing presence updates are intentionally silent. They
602
- // generated notification noise without adding durable progress evidence.
603
- const sendStatus = () => {}
620
+ const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
621
+ const sendStatus = (channelId, state) => {
622
+ const id = cycleContext.getStore()?.cycleId
623
+ const live = liveCycles.get(id)
624
+ if (live && !live.control.cancelled) activity.set(id, live.control.statusTargets.size ? live.control.statusTargets : [channelId], state)
625
+ }
604
626
  const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
627
+ const runtimeEvent = (type, data = {}) => {
628
+ emit(type, data)
629
+ const live = liveCycles.get(data.cycleId)
630
+ if (!live || live.control.cancelled || stopping) return
631
+ if (type === 'tool.started' || type === 'tool.updated') activity.set(data.cycleId, live.control.statusTargets, 'working')
632
+ if (type === 'output.progress') activity.set(data.cycleId, live.control.statusTargets, 'typing')
633
+ if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
634
+ live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
635
+ }
636
+ }
605
637
  const canCode = !!workdir
606
638
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
607
639
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -612,13 +644,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
612
644
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
613
645
  const runnerOptions = {
614
646
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
615
- cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: emit,
647
+ cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
648
+ getContextEnvironment: () => contextService.environment(),
616
649
  }
617
650
  // One watcher and one WS subscription, with a bounded pool of isolated coding
618
651
  // runtimes. Replies serialize within a thread while independent threads run
619
652
  // concurrently behind the same delivery guards.
620
653
  const MAX_CONCURRENT_WORKERS = 3
621
654
  const activeWorkRunners = new Set()
655
+ const recoveryWaiters = new Set()
656
+ const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
657
+ let eventConnection = 'unknown'
622
658
  const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
623
659
  ...runnerOptions,
624
660
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
@@ -640,12 +676,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
640
676
  const codexPushGuide = agent === 'codex' && canCode
641
677
  ? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
642
678
  : ''
643
- const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.'
679
+ const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.' + '\n\n' + contextToolGuide
644
680
  const studioGuide = `\n\nAGENT STUDIO: When a teammate asks to inspect agent activity or change model settings, offer [Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1). The app renders this link as a button. Studio manages agents on the teammate’s own computer; it does not connect to another teammate’s machine. Prefer this UI over asking people to run model-setting CLI commands.`
645
681
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule + studioGuide
646
682
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
647
683
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
648
- const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
684
+ const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request or replace the final answer with a plan. Task acknowledgements are delivered separately from your native plan; ordinary questions need only their answer. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
649
685
  // Live model state — changeable at runtime by the in-chat `/model` command.
650
686
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
651
687
  // ones, so routine chatter can run cheaper than real code work.
@@ -674,7 +710,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
674
710
  const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
675
711
  item.control.runner = runner
676
712
  activeWorkRunners.add(runner)
677
- try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir)) }
713
+ try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeRecoverableCycle(item, runner)) }
678
714
  finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
679
715
  },
680
716
  onError: (error, item) => {
@@ -721,6 +757,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
721
757
  databasePath: join(stateDir, 'watch-' + slug + '-mastra.db'),
722
758
  resourceId: identifier,
723
759
  })
760
+ const contextBackend = new URL(backend || mcpUrl || 'http://local.invalid')
761
+ const contextService = dependencies.contextService || createContextService({
762
+ directory: stateDir, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}`,
763
+ loadTasks: () => loadPendingTickets(),
764
+ listTools: async () => (await mcpClient.listTools()).map(toolWithoutCredentialInputs),
765
+ redact: (text) => apiKey ? String(text).split(apiKey).join('[redacted]') : String(text),
766
+ })
724
767
  // Completion delivery is runtime-owned for assigned coding work. Persist both
725
768
  // pending and delivered keys so a reconnect can finish a missed notification
726
769
  // without re-running the model or posting the same result twice.
@@ -729,7 +772,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
729
772
  const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
730
773
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
731
774
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
732
- // Transport/runtime failures are held at the ticket revision that produced them.
775
+ // Unrecoverable runtime failures are held at the ticket revision that produced them.
733
776
  // This is distinct from a policy block: any later human ticket change resumes
734
777
  // the work, but reconnects and the watcher's own blocker update do not.
735
778
  const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
@@ -856,6 +899,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
856
899
  emit('tool.started', { ...metadata, status: 'in_progress' })
857
900
  return trackOperation(Promise.resolve().then(() => mcpClient.callTool(name, args)).then((result) => {
858
901
  emit('tool.finished', { ...metadata, status: 'completed' })
902
+ try { contextService.observe({ name, args, result }) } catch { /* optional conversation indexing */ }
859
903
  return result
860
904
  }, (error) => {
861
905
  emit('tool.finished', { ...metadata, status: 'failed' })
@@ -1015,6 +1059,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1015
1059
  for (const [laneName, queue] of Object.entries(queues)) {
1016
1060
  queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
1017
1061
  item.control.cancelled = true
1062
+ item.control.wakeRecovery?.()
1018
1063
  log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
1019
1064
  void item.control.runner?.cancelCurrent?.('thread-cancelled')
1020
1065
  })
@@ -1043,7 +1088,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1043
1088
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1044
1089
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1045
1090
  })
1046
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1091
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
1047
1092
  if (stopping) throw new Error('Watcher is stopping')
1048
1093
  const deliveryKey = String(key || '')
1049
1094
  if (parentId == null) {
@@ -1060,6 +1105,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1060
1105
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
1061
1106
  const controlKey = threadControlKey(channelId, parentId)
1062
1107
  const suppressCancelled = () => {
1108
+ if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
1063
1109
  if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
1064
1110
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1065
1111
  pendingReplyDeliveries.delete(deliveryKey)
@@ -1079,7 +1125,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1079
1125
  const run = (async () => {
1080
1126
  if (parentId != null) {
1081
1127
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1082
- const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
1128
+ const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
1129
+ const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
1083
1130
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
1084
1131
  if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
1085
1132
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
@@ -1104,6 +1151,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1104
1151
  content: message,
1105
1152
  }))
1106
1153
  const postedId = result.id ?? result.message?.id ?? result.message_id
1154
+ if (postedId != null) contextService.observe({ name: 'post_message', args: { channel_id: channelId, parent_id: parentId, content: message }, result: { id: postedId } })
1107
1155
  if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
1108
1156
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1109
1157
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
@@ -1117,6 +1165,26 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1117
1165
  return run
1118
1166
  }
1119
1167
 
1168
+ async function publishWorkPlan(live, entries) {
1169
+ const { control, delivery, taskRef } = live
1170
+ const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
1171
+ if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1172
+ const content = `${control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on."}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1173
+ const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1174
+ if (channelId == null || control.cancelled) return
1175
+ const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
1176
+ // Remember intent before a potentially ambiguous write. A later final reply
1177
+ // must not be mistaken for a duplicate of this plan, even after reconnect.
1178
+ memory.remember({ key: control.planKey, kind: 'work-plan', state: 'attempted', summary: content, refs, meta: { sourceKey: delivery?.sourceKey || '' } })
1179
+ try {
1180
+ await postMessageOnce({ key: control.planKey + ':delivery', channelId, parentId: delivery?.parentId, projectId: taskRef?.projectId, sourceKey: delivery?.sourceKey, sourceMessageId: delivery?.sourceMessageId, content, isCancelled: () => control.cancelled })
1181
+ } finally {
1182
+ // An acknowledgement is only useful before work completes. Never leave a
1183
+ // delayed pickup message queued behind a delivered final response.
1184
+ pendingReplyDeliveries.delete(control.planKey + ':delivery'); persistReplay()
1185
+ }
1186
+ }
1187
+
1120
1188
  let deliveryProbeBusy = false
1121
1189
  const flushPendingReplies = async () => {
1122
1190
  if (stopping || deliveryProbeBusy) return
@@ -1473,21 +1541,57 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1473
1541
  const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1474
1542
  const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1475
1543
  const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
1544
+ liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
1476
1545
  emit('cycle.queued', { ...cycleData, status: 'queued' })
1477
1546
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
1478
1547
  const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
1479
1548
  emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1549
+ liveCycles.delete(cycleId)
1550
+ activity.clear(cycleId)
1480
1551
  return result
1481
1552
  })
1482
1553
  }
1483
1554
 
1555
+ async function executeRecoverableCycle(item, runner) {
1556
+ const { control } = item
1557
+ let partialOutput = ''
1558
+ for (let attempt = 1; !stopping && !control.cancelled; attempt++) {
1559
+ control.attempt = attempt
1560
+ let interruption
1561
+ const continuation = attempt === 1 ? '' : '\n\nTRANSPORT RECOVERY: The previous runtime was interrupted before an agent final response. Continue the original request in the existing workspace. Inspect current files, git state, and live task/thread history first; edits and remote writes may already have succeeded. Do not blindly repeat commits, ticket creation, comments, or other writes. Verify uncertain outcomes before acting. You decide the remaining work and when to finish.' + (partialOutput ? '\nPrevious partial output (not a final result):\n' + partialOutput : '')
1562
+ try {
1563
+ interruption = await executeCycle(item.kind, item.context + continuation, item.targetChannels, item.taskRef, item.delivery, runner, control, item.workdir)
1564
+ if (!interruption?.transportInterrupted) return
1565
+ partialOutput = String(interruption.outputText || partialOutput).slice(-12000)
1566
+ } catch (error) {
1567
+ if (stopping || control.cancelled) return
1568
+ if (attempt > recoveryDelays.length || !isTransportInterruption(error)) throw error
1569
+ }
1570
+ if (stopping || control.cancelled) return
1571
+ control.outcome = 'recovering'
1572
+ activity.clear(control.cycleId)
1573
+ const delayMs = recoveryDelays[attempt - 1]
1574
+ emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
1575
+ log(`coding runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
1576
+ await new Promise((resolve) => {
1577
+ const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
1578
+ // A known event-channel outage can last longer than the retry budget.
1579
+ // Keep accepted work pending until reconnect instead of burning model
1580
+ // attempts while offline. Provider-only failures still use backoff.
1581
+ const timer = setTimeout(() => { if (eventConnection !== 'disconnected') wake() }, delayMs)
1582
+ recoveryWaiters.add(wake)
1583
+ control.wakeRecovery = wake
1584
+ })
1585
+ }
1586
+ }
1587
+
1484
1588
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1485
1589
  refreshModelSettings()
1486
1590
  const laneName = kind === 'full' ? 'work' : 'reply'
1487
1591
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1488
1592
  const cycleStartedAt = performance.now()
1489
- cycleControl.startedAt = cycleStartedAt
1490
- cycleControl.outcome = 'skipped'
1593
+ cycleControl.startedAt ??= cycleStartedAt
1594
+ cycleControl.outcome = 'preflight'
1491
1595
  emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
1492
1596
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1493
1597
  const ctx = context ? [context] : []
@@ -1495,12 +1599,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1495
1599
  let verifiedTaskRevision = ''
1496
1600
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1497
1601
  const runnerOptions = {
1498
- cycleId: cycleControl.cycleId, attempt: 1,
1602
+ cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
1499
1603
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1500
1604
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1501
1605
  }
1502
1606
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
1503
- cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
1607
+ cycleControl.statusTargets = new Set(targets)
1504
1608
  if (laneName === 'reply') {
1505
1609
  replyStatusTargets.clear()
1506
1610
  for (const target of targets) replyStatusTargets.add(target)
@@ -1523,8 +1627,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1523
1627
  const cycleModelRevision = modelSettingsRevision
1524
1628
  const useModel = kind === 'full' ? codeModel : liteModel
1525
1629
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1526
- // Presence notifications are intentionally disabled; durable messages and
1527
- // ticket transitions are the only user-visible progress signals.
1630
+ activity.set(cycleControl.cycleId, cycleControl.statusTargets, 'thinking')
1528
1631
  try {
1529
1632
  // The ticket may have been reassigned or handed to review while waiting.
1530
1633
  // Re-check at dequeue, before any model can edit the repository.
@@ -1532,19 +1635,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1532
1635
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1533
1636
  const ticket = data.ticket ?? data.task ?? data
1534
1637
  verifiedTaskRevision = taskRevision(ticket)
1638
+ cycleControl.taskLabel = ticketDisplaySlug(ticket) || String(ticket.title || 'the task').slice(0, 160)
1535
1639
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1536
1640
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1537
1641
  !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
1538
1642
  taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1539
1643
  releaseTaskForRetry(activeTaskRef, prompt)
1644
+ cycleControl.outcome = 'skipped'
1540
1645
  log('queued ticket no longer actionable; skipped before model start')
1541
1646
  return
1542
1647
  }
1543
1648
  }
1544
1649
  if (cycleControl.cancelled) return
1650
+ cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
1545
1651
  cycleControl.outcome = 'running'
1546
1652
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1547
1653
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1654
+ await cycleControl.planDelivery
1548
1655
  cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
1549
1656
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1550
1657
  refreshModelSettings()
@@ -1568,6 +1675,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1568
1675
  return
1569
1676
  }
1570
1677
  if (!cycleSucceeded(result)) {
1678
+ if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
1571
1679
  const outcome = result?.subtype || 'an unknown runtime error'
1572
1680
  const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
1573
1681
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
@@ -1624,6 +1732,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1624
1732
  }
1625
1733
  }
1626
1734
  } finally {
1735
+ activity.clear(cycleControl.cycleId)
1627
1736
  cycleControl.statusTargets.clear()
1628
1737
  }
1629
1738
  }
@@ -1686,6 +1795,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1686
1795
  for (const [name, queue] of Object.entries(queues)) {
1687
1796
  queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
1688
1797
  item.control.cancelled = true
1798
+ item.control.wakeRecovery?.()
1689
1799
  void item.control.runner?.cancelCurrent?.()
1690
1800
  })
1691
1801
  }
@@ -1851,11 +1961,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1851
1961
  }
1852
1962
  if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
1853
1963
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1964
+ try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
1965
+ catch { /* optional conversation indexing */ }
1854
1966
  // Every runtime carries the source thread through the queue so a later
1855
- // redirect can cancel its process. Codex additionally delegates the final
1856
- // post to the watcher for an authoritative last-moment thread check.
1967
+ // redirect can cancel its process. The watcher delivers the final answer
1968
+ // for every runtime after an authoritative last-moment thread check.
1857
1969
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1858
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: agent === 'codex' }
1970
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
1859
1971
  : null
1860
1972
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1861
1973
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1940,7 +2052,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1940
2052
  // recover channel mentions even when the backend omits targeted dispatch.
1941
2053
  // Both event types share recipient, ownership and deduplication guards.
1942
2054
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1943
- handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250) }, log })
2055
+ handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
2056
+ onDisconnect: () => {
2057
+ eventConnection = 'disconnected'
2058
+ emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
2059
+ },
2060
+ onConnect: () => {
2061
+ if (stopping) return
2062
+ eventConnection = 'connected'
2063
+ activity.pulse()
2064
+ emit('connection.connected', { status: 'connected' })
2065
+ for (const wake of [...recoveryWaiters]) wake()
2066
+ if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
2067
+ }, log })
1944
2068
 
1945
2069
  let authorizationProbeBusy = false
1946
2070
  const legacyAuthorizationChecks = new Set()
@@ -1981,7 +2105,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1981
2105
  const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
1982
2106
  modelSettingsTimer?.unref?.()
1983
2107
  emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
1984
- const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' }), 15_000) : null
2108
+ const heartbeatTimer = autoStart ? setInterval(() => {
2109
+ emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' })
2110
+ // The Studio reads a bounded journal tail. Re-state current queue ownership
2111
+ // so a long-running cycle stays visible after its start event rotates out.
2112
+ for (const { data, control } of liveCycles.values()) {
2113
+ if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
2114
+ }
2115
+ }, 15_000) : null
1985
2116
  heartbeatTimer?.unref?.()
1986
2117
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1987
2118
  if (autoStart && (mcpConfig || mcpUrl)) {
@@ -2021,12 +2152,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2021
2152
  if (taskProbeTimer) clearInterval(taskProbeTimer)
2022
2153
  try { handle?.close() } catch { /* already disconnected */ }
2023
2154
  for (const queue of Object.values(queues)) {
2024
- queue.cancel(() => true, (item) => { item.control.cancelled = true })
2155
+ queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
2025
2156
  }
2026
2157
  await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
2027
2158
  await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
2028
2159
  workRunners.close()
2029
2160
  replyRunners.close()
2161
+ await activity.close()
2162
+ try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
2030
2163
  try { await memory.settled?.() }
2031
2164
  catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
2032
2165
  finally {
package/src/ws.mjs CHANGED
@@ -37,7 +37,7 @@ export function assertWebSocket(fail) {
37
37
  * @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
38
38
  * @param {(msg: string) => void} o.log
39
39
  */
40
- export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
40
+ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, onDisconnect, log }) {
41
41
  const base = stripSlash(wsUrl)
42
42
  const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
43
43
 
@@ -96,10 +96,11 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
96
96
  })
97
97
 
98
98
  const down = (why) => {
99
- if (sock !== ws) return
99
+ if (closed || sock !== ws) return
100
100
  clearKeepalive()
101
101
  ws = null
102
102
  log(why)
103
+ try { onDisconnect?.() } catch { /* connection telemetry is optional */ }
103
104
  scheduleReconnect()
104
105
  }
105
106
  // $connect 401 (bad/inactive credentials) surfaces as an immediate close.
package/studio/app.mjs CHANGED
@@ -82,7 +82,7 @@ export function deriveModel(input) {
82
82
  if (event.type.startsWith('cycle.')) {
83
83
  if (data.kind != null) cycle.data.kind = data.kind;
84
84
  cycle.status = eventStatus(event);
85
- if (event.type === 'cycle.started') cycle.startedAt = event.timestamp;
85
+ if (event.type === 'cycle.started') cycle.startedAt ||= event.timestamp;
86
86
  if (terminalStatuses.has(cycle.status)) cycle.finishedAt = event.timestamp;
87
87
  }
88
88
  if (event.type.startsWith('tool.')) {
package/studio/guide.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <a href="#start">Open Studio</a>
22
22
  <a href="#watchers">Show your agents</a>
23
23
  <a href="#find-work">Find and inspect work</a>
24
+ <a href="#recall">Tasks and conversation recall</a>
24
25
  <a href="#statuses">Understand statuses</a>
25
26
  <a href="#controls">Pause and follow</a>
26
27
  <a href="#models">Change models</a>
@@ -71,6 +72,12 @@
71
72
  <p><strong>No explicit plan recorded</strong> means the provider did not send a plan in the available history. A missing process ID means the provider did not report that operating-system identifier. Neither message alone means the work failed.</p>
72
73
  <p>The process summary counts distinct reported process IDs associated with connected watchers. It is not a system-wide process monitor. The demo deliberately shows no real process count.</p>
73
74
  </section>
75
+ <section id="recall">
76
+ <h2>Tasks and conversation recall</h2>
77
+ <p>Tell your agent “I have a task for you” or ask it to check its assignments. The agent has a live task lookup, a searchable tool guide, and short skills it can load when helpful. A lookup does not itself claim, start, or complete a task.</p>
78
+ <p>Ask about an earlier decision, such as “What did we decide about credential storage?” The agent can search recorded conversations by meaning and return to the original channel or thread for verification. It searches messages it has observed or fetched, so conversations it has never read may be missing.</p>
79
+ <p>The first semantic search downloads a local embedding model. Keyword search stays available while it loads. Later inference runs on this computer without an embedding API key. The agent chooses what context to retrieve and when to end its turn.</p>
80
+ </section>
74
81
  <section id="statuses">
75
82
  <h2>Understand statuses</h2>
76
83
  <dl>
package/studio/style.css CHANGED
@@ -45,6 +45,8 @@
45
45
  button:disabled { cursor: default; opacity: .6; }
46
46
  button, a, input, select, summary { touch-action: manipulation; }
47
47
  :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
48
+ input:focus, select:focus, textarea:focus { outline: none; }
49
+ input:focus-visible, select:focus-visible, textarea:focus-visible { box-shadow: inset 0 -2px 0 var(--focus); }
48
50
  button, select, a { transition: background-color .16s ease-out, color .16s ease-out, box-shadow .16s ease-out; }
49
51
  h1, h2, h3, h4, p { margin: 0; }
50
52
  h1, h2, h3, h4, strong { font-weight: 500; }
@@ -76,7 +78,7 @@
76
78
  .count { font-size: 12px; background: var(--stage); min-width: 22px; height: 19px; display: grid; place-items: center; border-radius: 999px; font-variant-numeric: tabular-nums; }
77
79
  .agent-search { display: flex; align-items: center; gap: 7px; padding: 8px 10px; margin: 0 0 12px; color: var(--subtle); }
78
80
  .agent-search .icon { width: 14px; height: 14px; }
79
- .agent-search input { width: 100%; border: 0; outline-offset: 5px; background: transparent; font-size: 12px; }
81
+ .agent-search input { width: 100%; border: 0; background: transparent; font-size: 12px; }
80
82
  .agent-list { display: flex; flex-direction: column; gap: 5px; }
81
83
  .agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
82
84
  .agent-item:hover { background: var(--hover); }
@@ -123,7 +125,7 @@
123
125
  .filters { display: flex; gap: 9px; margin-bottom: 19px; }
124
126
  .search-field { flex: 1; min-width: 90px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: 999px; padding: 8px 12px; color: var(--subtle); }
125
127
  .search-field .icon { width: 14px; height: 14px; }
126
- .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; outline-offset: 4px; }
128
+ .search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; }
127
129
  .select-field { display: inline-flex; align-items: center; min-width: 108px; }
128
130
  .select-field select { width: 100%; min-height: 40px; padding: 7px 26px 7px 12px; border: 1px solid var(--border); border-radius: 999px; background-color: var(--surface); font-size: 12px; }
129
131
  .content-grid { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 14px; align-items: start; }
@@ -360,7 +362,6 @@
360
362
  .model-settings-form label { display: grid; gap: 4px; font-size: 13px; margin-top: 8px; }
361
363
  .model-settings-form label span { font-size: 12px; color: var(--muted); }
362
364
  .model-settings-form input { width: 100%; min-width: 0; padding: 11px 14px; border: 1px solid var(--border); border-radius: 18px; background: transparent; color: var(--ink); font-size: 13px; }
363
- .model-settings-form input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
364
365
  .model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
365
366
  #model-settings-error { color: #9f3030; }
366
367
  @media (max-width: 640px) {