@comput/pi-intercom 0.4.1 → 0.5.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/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "bugs": {
13
13
  "url": "https://github.com/comput-sh/pi-intercom/issues"
14
14
  },
15
- "version": "0.4.1",
15
+ "version": "0.5.1",
16
16
  "description": "A coordinator-led workspace for Pi. Delegate to workers with separate contexts, evaluate their reports, and keep the main conversation focused—with the team visible alongside it.",
17
17
  "type": "module",
18
18
  "keywords": [
@@ -33,16 +33,14 @@
33
33
  "references/implementation-blocker.md"
34
34
  ],
35
35
  "engines": {
36
- "node": ">=22"
36
+ "node": ">=22.19.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@earendil-works/pi-tui": "*",
40
39
  "@earendil-works/pi-coding-agent": "*",
41
40
  "typebox": "*"
42
41
  },
43
42
  "devDependencies": {
44
43
  "@earendil-works/pi-coding-agent": "0.84.4",
45
- "@earendil-works/pi-tui": "^0.84.4",
46
44
  "@types/node": "^22.0.0",
47
45
  "typebox": "1.3.7",
48
46
  "typescript": "^5.9.3"
@@ -51,8 +49,12 @@
51
49
  "build": "node -e \"require('node:fs').rmSync('dist', {recursive:true, force:true})\" && tsc",
52
50
  "prepack": "npm run build",
53
51
  "typecheck": "tsc --noEmit",
54
- "test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/linux-launcher.test.mjs tests/activity.test.mjs tests/reports.test.mjs tests/worker-status.test.mjs tests/monitor.test.mjs tests/monitor-view.test.mjs tests/monitor-launcher.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs tests/observability.test.mjs tests/snapshot.test.mjs tests/snapshot-security.test.mjs tests/observability-integration.test.mjs",
52
+ "test": "npm run build && node --test tests/core.test.mjs tests/integration.test.mjs tests/launcher.test.mjs tests/linux-launcher.test.mjs tests/activity.test.mjs tests/reports.test.mjs tests/worker-status.test.mjs tests/connections.test.mjs tests/monitor.test.mjs tests/monitor-view.test.mjs tests/monitor-launcher.test.mjs tests/extension.test.mjs tests/runtime-http.test.mjs tests/resume-storage.test.mjs tests/observability.test.mjs tests/snapshot.test.mjs tests/snapshot-security.test.mjs tests/observability-integration.test.mjs",
55
53
  "test:host": "node --test tests/pi-cancellation-probe.test.mjs",
56
- "check:package": "npm run build && npm pack --dry-run --ignore-scripts"
54
+ "test:package": "node --test tests/packaged-monitor.test.mjs",
55
+ "check:package": "npm run build && npm pack --dry-run --ignore-scripts && npm run test:package"
56
+ },
57
+ "dependencies": {
58
+ "pi-intercom-tui": "npm:@earendil-works/pi-tui@0.84.4"
57
59
  }
58
60
  }
@@ -1,16 +1,17 @@
1
1
  # Local observations and terminal monitor
2
2
 
3
- Intercom's terminal monitor is a standalone Node/pi-tui process above the coordinator in Herdr. It reads local configuration and metadata logs; it is not an agent, scheduler, durable task journal, or proof of liveness. Included in npm release 0.4.1, replacing the browser dashboard. See [README](../README.md) for installation and validation scope.
3
+ Intercom's terminal monitor is a standalone Node/pi-tui process above the coordinator in Herdr. It reads local configuration and metadata logs; it is not an agent, scheduler, durable task journal, or proof of liveness. The basic monitor was included in npm release 0.4.1, replacing the browser dashboard. Connectivity checks/disconnected sorting described below are included in 0.5.1. See [README](../README.md) for installation and validation scope.
4
4
 
5
5
  ## Terminal-first monitoring
6
6
 
7
7
  Coordinator startup checks its saved monitor ownership and either preserves the existing pane or creates one above the coordinator. Workers do not create monitors. The pane is named `Intercom monitor`; its generic label alone is not proof of ownership. Machine-local `monitor-*.json` files record the coordinator/workspace/pane identities and partial-launch state. Uncertain operations are fenced rather than blindly repeated.
8
8
 
9
- The monitor refreshes from disk approximately every three seconds after the previous read finishes. It never probes workers, launches model turns, or resends messages. Arrow keys select a worker; Enter toggles its details; Escape closes details before quitting, while q/Ctrl+C always quit the monitor only. Selection follows the worker's session ID across roster reorder. Details show configured responsibility, observations and explicit public worker reports, not inferred task state or approval controls.
9
+ The monitor refreshes from disk and performs bounded loopback identity checks, then schedules the next pass approximately three seconds after completion. It never launches model turns or resends messages. Probes do not write config or observation logs; cancelling the monitor aborts in-flight probes. Arrow keys select a worker; Enter toggles its details; Escape closes details before quitting, while q/Ctrl+C always quit the monitor only. Selection follows the worker's session ID across roster reorder. Details show configured responsibility, observations and explicit public worker reports, not inferred task state or approval controls.
10
10
 
11
11
  - **Worker:** configured name. A roster entry does not prove a process exists.
12
12
  - **Seen:** age of the status observation, not the last redraw. Evidence older than 60 seconds is marked `(old)`.
13
- - **Status:** last observed working, thinking, idle, closed or unknown. Closed means extension runtime closure, not guaranteed process death. Idle is not a task-completion assertion.
13
+ - **Connection:** connected, disconnected or unknown, separate from activity. Details show fixed reason/check time. Disconnected workers sort last; names, responsibilities and session IDs remain registered.
14
+ - **Activity:** last observed working, thinking, idle, closed or unknown. Closed means extension runtime closure, not guaranteed process death. Idle is not a task-completion assertion.
14
15
  - **Last activity:** a fixed public description of observed activity, which may remain after settlement. No reasoning or tool payload is displayed.
15
16
  - **Report:** explicitly worker-authored blocked, needs-decision or ready-for-review state, with its own timestamp and public summary in details. It is never coordinator approval or inferred from observed activity.
16
17
  - **Unknown/partial:** missing, conflicting, future-dated or unreadable evidence is not guessed. Bounded history and dropped observations can make the view incomplete.
@@ -19,7 +20,7 @@ Names and descriptions are sanitized for terminal controls and clipped to availa
19
20
 
20
21
  ## Retired browser dashboard
21
22
 
22
- Version 0.4.1 no longer starts a dashboard HTTP server, ships browser assets, or returns `dashboardUrl` from `intercom_list`. Only Intercom's messaging HTTP endpoints remain.
23
+ Version 0.4.1 no longer starts a dashboard HTTP server, ships browser assets, or returns `dashboardUrl` from `intercom_list`. Only Intercom's loopback agent HTTP listeners remain; 0.5.1 adds a side-effect-free health route to those existing listeners.
23
24
 
24
25
  Old coordinator-only `dashboardPort` fields are accepted for compatibility and removed through a guarded coordinator config update at startup. Listing never advertises the retired port, even if migration failed; a migration warning does not disable messaging. Reload an older running coordinator to close its existing dashboard listener and load the new implementation.
25
26
 
@@ -49,9 +50,19 @@ Event types: `runtime.starting`, `runtime.ready`, `runtime.closed`, `runtime.fai
49
50
 
50
51
  Activity phases are `working`, `thinking`, `responding`, `tool`, `idle`. Details are `processing`, `thinking`, `responding`, `reading_files`, `editing_files`, `running_command`, `using_tool`, `multiple_tools`, `settled`. Only event discriminants and built-in tool categories are inspected. Repeated streaming phases are deduplicated, and concurrent tools take precedence over streaming hints. Thinking events are provider-dependent; missing events do not establish absence of reasoning.
51
52
 
53
+ ## Connectivity and retained sessions
54
+
55
+ `GET /intercom/health` projects only version and Pi session ID after the originating runtime's lifecycle guard. It does not call the model/message acceptance path or expose prompts, arguments, results or host internals. Identity matching is correlation on trusted loopback, not authentication or a process-lock guarantee.
56
+
57
+ `connections.ts` sends HTTP only to configured `127.0.0.1` ports, without redirects/proxies/retries. Limits: 4 KiB response headers, 1 KiB body, 750 ms total per-request deadline by default (including slow bodies), 4 s batch budget, 16 concurrent checks, first 256 inputs/outputs. Skipped/cancelled work is unknown, not disconnected. Missing health support, malformed data and unsupported versions remain unknown; refusal/deadline failure or mismatched session ID means disconnected/unmatched endpoint, never proven process termination.
58
+
59
+ Checks expire to unknown after 30 seconds. Monitor batches rotate to avoid starvation; skipped checks may retain previous unexpired evidence. Saved-port changes/removal invalidate cached checks. Shared classification/sorting preserves config order within connected-or-unknown and disconnected groups. UI selection follows session ID across these order changes.
60
+
61
+ Closing a worker does not remove config. Explicit resume uses the saved session ID, name and responsibility and requires user-confirmed closure (`confirmClosed:true`). A live matching health endpoint rejects duplicate resume. In-flight/uncertain submissions are fenced within this coordinator runtime until a later configured status announcement; preflight announcements do not qualify, and uncertain launch errors preserve the fence. Config/identity/lifecycle are rechecked before launch. These safeguards do not guarantee cross-process uniqueness, survive coordinator restart or certify OS process death. No automatic close, retry, restart or work assignment occurs.
62
+
52
63
  ## Read-only JSON status
53
64
 
54
- `intercom_worker_status` reads this same bounded snapshot, using the same `workerObservation` projection as the monitor. It returns configured workers only, optionally selected by name, with last-observed status/activity, timestamp, age, stale/unknown/conflicting evidence, and independent public reports. Missing or future evidence does not imply health. Clear tombstones appear as null reports.
65
+ `intercom_worker_status` reads this same bounded snapshot, using the same `workerObservation` projection as the monitor. It returns configured workers only, optionally selected by name, with last-observed status/activity, timestamp, age, stale/unknown/conflicting evidence, and independent public reports. Missing or future evidence does not imply health. Clear tombstones appear as null reports. Default reads remain local-only with unknown/not_checked connections. `probe:true` checks only the selected page and returns separate connection data; disconnected entries sort last within that page. Page boundaries remain in saved config order, not global connection order.
55
66
 
56
67
  Pages default to 10 entries (limit 1–20), capped at 40 KiB before the final pagination field, below Pi's tool truncation limits. Follow `nextOffset`; each page is a new local snapshot, not a transaction spanning changing rosters. Snapshot truncation/errors are retained. No model calls, status requests, worker messages, Telegram dependencies or hidden reasoning are involved. Any presentation layer can consume the JSON.
57
68
 
@@ -0,0 +1,142 @@
1
+ import http from 'node:http';
2
+ import { setMaxListeners } from 'node:events';
3
+
4
+ export type ConnectionReason = 'verified' | 'identity_mismatch' | 'unsupported_version' | 'refused' | 'timeout' | 'legacy' | 'malformed' | 'oversize' | 'http_error' | 'network_error' | 'aborted' | 'not_checked' | 'stale' | 'invalid_input';
5
+ export interface WorkerConnection {
6
+ sessionId: string;
7
+ state: 'connected' | 'disconnected' | 'unknown';
8
+ checkedAt: string | null;
9
+ reason: ConnectionReason;
10
+ }
11
+ export interface ConnectionAgent { sessionId: string; port: number }
12
+ export interface ProbeOptions { signal?: AbortSignal; timeoutMs?: number }
13
+ export interface BatchProbeOptions extends ProbeOptions { budgetMs?: number; concurrency?: number }
14
+ const MAX_BODY = 1024;
15
+ const MAX_AGE = 30000;
16
+ const MAX_WORKERS = 256;
17
+
18
+ function bounded(value: number | undefined, fallback: number, maximum: number): number {
19
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.min(maximum, Math.floor(value))) : fallback;
20
+ }
21
+ function unknown(sessionId: string, reason: ConnectionReason = 'not_checked', checkedAt: string | null = null): WorkerConnection {
22
+ return { sessionId, state: 'unknown', checkedAt, reason };
23
+ }
24
+
25
+ /** Read-only best-effort endpoint identity, never evidence of process termination. */
26
+ export function probeWorker(agent: ConnectionAgent, options: ProbeOptions = {}): Promise<WorkerConnection> {
27
+ if (options.signal?.aborted) return Promise.resolve(unknown(agent.sessionId, 'aborted'));
28
+ if (typeof agent.sessionId !== 'string' || !agent.sessionId.trim() || agent.sessionId.length > 256 || !Number.isInteger(agent.port) || agent.port < 1 || agent.port > 65535) {
29
+ return Promise.resolve(unknown(agent.sessionId, 'invalid_input'));
30
+ }
31
+ const timeout = bounded(options.timeoutMs, 750, 4000);
32
+ return new Promise(resolve => {
33
+ let settled = false;
34
+ let request: http.ClientRequest | undefined;
35
+ let response: http.IncomingMessage | undefined;
36
+ let timer: ReturnType<typeof setTimeout> | undefined;
37
+ const finish = (state: WorkerConnection['state'], reason: ConnectionReason) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ options.signal?.removeEventListener('abort', abort);
42
+ response?.destroy();
43
+ request?.destroy();
44
+ resolve(options.signal?.aborted
45
+ ? unknown(agent.sessionId, 'aborted', new Date().toISOString())
46
+ : { sessionId: agent.sessionId, state, reason, checkedAt: new Date().toISOString() });
47
+ };
48
+ const abort = () => finish('unknown', 'aborted');
49
+ const error = (error: NodeJS.ErrnoException) => {
50
+ if (error.code === 'ECONNREFUSED') finish('disconnected', 'refused');
51
+ else if (error.code === 'ETIMEDOUT') finish('disconnected', 'timeout');
52
+ else finish('unknown', 'network_error');
53
+ };
54
+ options.signal?.addEventListener('abort', abort, { once: true });
55
+ // Wall-clock deadline covers connect, headers AND the entire body, including
56
+ // slow-drip responses. Native HTTP ignores proxy env; no redirects/retries.
57
+ timer = setTimeout(() => finish('disconnected', 'timeout'), timeout);
58
+ try {
59
+ request = http.get({ hostname: '127.0.0.1', port: agent.port, path: '/intercom/health', agent: false, maxHeaderSize: 4096 }, res => {
60
+ response = res;
61
+ res.on('error', error);
62
+ if (settled) { res.destroy(); return; }
63
+ if (res.statusCode !== 200) {
64
+ finish('unknown', res.statusCode === 404 ? 'legacy' : 'http_error');
65
+ return;
66
+ }
67
+ let bytes = 0;
68
+ const chunks: Buffer[] = [];
69
+ res.on('data', (chunk: Buffer) => {
70
+ bytes += chunk.length;
71
+ if (bytes > MAX_BODY) { finish('unknown', 'oversize'); return; }
72
+ chunks.push(chunk);
73
+ });
74
+ res.on('aborted', () => finish('unknown', 'network_error'));
75
+ res.on('end', () => {
76
+ if (settled) return;
77
+ let body: unknown;
78
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
79
+ catch { finish('unknown', 'malformed'); return; }
80
+ if (!body || typeof body !== 'object' || Array.isArray(body) ||
81
+ typeof (body as { version?: unknown }).version !== 'number' ||
82
+ typeof (body as { sessionId?: unknown }).sessionId !== 'string' ||
83
+ !(body as { sessionId: string }).sessionId.trim() || (body as { sessionId: string }).sessionId.length > 256) {
84
+ finish('unknown', 'malformed'); return;
85
+ }
86
+ const identity = body as { version: number; sessionId: string };
87
+ if (identity.version !== 1) finish('unknown', 'unsupported_version');
88
+ else if (identity.sessionId !== agent.sessionId) finish('disconnected', 'identity_mismatch');
89
+ else finish('connected', 'verified');
90
+ });
91
+ });
92
+ request.on('error', error);
93
+ } catch { finish('unknown', 'network_error'); }
94
+ });
95
+ }
96
+
97
+ /** One bounded concurrent pass; cancellation/budget exhaustion never implies disconnection. */
98
+ export async function probeWorkers(agents: readonly ConnectionAgent[], options: BatchProbeOptions = {}): Promise<WorkerConnection[]> {
99
+ const boundedAgents = agents.slice(0, MAX_WORKERS);
100
+ const results = boundedAgents.map(agent => unknown(agent.sessionId));
101
+ const controller = new AbortController();
102
+ setMaxListeners(17, controller.signal);
103
+ const abort = () => controller.abort();
104
+ const budget = bounded(options.budgetMs, 4000, 4000);
105
+ const concurrency = bounded(options.concurrency, 16, 16);
106
+ if (options.signal?.aborted) return boundedAgents.map(agent => unknown(agent.sessionId, 'aborted'));
107
+ options.signal?.addEventListener('abort', abort, { once: true });
108
+ const timer = setTimeout(abort, budget);
109
+ let next = 0;
110
+ try {
111
+ await Promise.all(Array.from({ length: Math.min(concurrency, boundedAgents.length) }, async () => {
112
+ while (!controller.signal.aborted && next < boundedAgents.length) {
113
+ const index = next++;
114
+ results[index] = await probeWorker(boundedAgents[index], { signal: controller.signal, timeoutMs: options.timeoutMs });
115
+ }
116
+ }));
117
+ return results;
118
+ } finally {
119
+ clearTimeout(timer);
120
+ options.signal?.removeEventListener('abort', abort);
121
+ controller.abort();
122
+ }
123
+ }
124
+
125
+ /** Health expires: an old failure must not indefinitely demote a configured worker. */
126
+ export function currentConnection(sessionId: string, connections?: readonly WorkerConnection[], now = Date.now()): WorkerConnection {
127
+ const connection = connections?.find(item => item.sessionId === sessionId);
128
+ if (!connection) return unknown(sessionId);
129
+ if (connection.checkedAt === null) return unknown(sessionId, connection.state === 'unknown' ? connection.reason : 'not_checked');
130
+ const checked = Date.parse(connection.checkedAt);
131
+ if (!Number.isFinite(checked) || !Number.isFinite(now) || checked > now || now - checked > MAX_AGE) return unknown(sessionId, 'stale', connection.checkedAt);
132
+ return { ...connection };
133
+ }
134
+
135
+ /** Stable disconnected-last copy; does not mutate configuration or observations. */
136
+ export function sortWorkersByConnection<T extends { sessionId: string }>(workers: readonly T[], connections?: readonly WorkerConnection[], now = Date.now()): T[] {
137
+ const current: T[] = [], disconnected: T[] = [];
138
+ for (const worker of workers) {
139
+ (currentConnection(worker.sessionId, connections, now).state === 'disconnected' ? disconnected : current).push(worker);
140
+ }
141
+ return [...current, ...disconnected];
142
+ }
package/src/index.ts CHANGED
@@ -14,14 +14,14 @@ const tools: [string, string, TSchema][] = [
14
14
  ['configure_worker', 'Coordinator only. Write explicit reported connection details and responsibility to config. Does not notify, reload, or start work. Use reload_worker separately.', Type.Object({ sessionId: Type.String(), port: Type.Integer({ minimum: 1, maximum: 65535 }), projectDirectory: Type.String(), name: Type.String({ minLength: 1, maxLength: 128 }), description: Type.String({ minLength: 1, maxLength: 4096 }) })],
15
15
  ['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
16
16
  ['send', 'Configured sessions only. Send an explicit work/progress/findings message by recipient name. Idle delivery starts a turn; busy delivery steers at supported boundaries. Receipt is not an agent reply. Progress questions do not cancel assignments.', Type.Object({ to: Type.String(), message: Type.String({ minLength: 1, maxLength: 48000 }) })],
17
- ['worker_status', 'Configured sessions only. Read paginated JSON worker observations and public reports from local files, optionally by worker name. Last-observed status is not live liveness or task completion; reports are self-reported, not approval. No worker prompts, live probing or Telegram dependency. Follow nextOffset for further pages; null report means no readable active report.', Type.Object({ name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })) })],
17
+ ['worker_status', 'Configured sessions only. Read paginated JSON worker observations and public reports from local files, optionally by worker name. Last-observed status is not live liveness or task completion; reports are self-reported, not approval. Local-only by default; probe:true performs bounded loopback identity checks for this page only, without worker prompts or Telegram dependency. Disconnected means endpoint unreachable, not process termination. Follow nextOffset for further pages; null report means no readable active report.', Type.Object({ name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })), probe: Type.Optional(Type.Boolean()) })],
18
18
  ['list', 'Configured sessions only. Read current saved names, responsibilities, session IDs, roles and ports. No live probing. Output limited to 50 KiB/2000 lines; full data remains in config.json.', Type.Object({})],
19
19
  ['request_status', 'Coordinator only. Ask worker extension to send an independent sessionId/port/busy report without a model turn on worker. No synchronous status reply.', to],
20
20
  ['report_work', 'Configured, responsibility-loaded workers only. Store one public report (blocked, needs_decision, ready_for_review) and notify the coordinator; clear retires it. Summary required except clear (omit or empty). Never include private reasoning, credentials, raw tool payloads, or secrets. Receipt is not approval, verified completion, or permission for follow-up work.', Type.Object({ status: Type.String({ enum: ['blocked', 'needs_decision', 'ready_for_review', 'clear'] }), summary: Type.Optional(Type.String({ maxLength: 2000 })) })],
21
21
  ['report_status', 'Worker only (including anonymous). Send runtime sessionId/port/busy report to coordinator. This is NOT registration and creates no config entry.', Type.Object({})],
22
22
  ['stop_worker', UNSUPPORTED_CANCELLATION, to],
23
23
  ['close_worker', UNSUPPORTED_CANCELLATION, to],
24
- ['resume_worker', 'Coordinator only. Launch saved session by ID in configured directory. No replacement/removal on failure; no old assignment is automatically started.', to],
24
+ ['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
25
25
  ['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Stop/close are disabled on current host: arrange explicit user closure first.', to],
26
26
  ['set_multiplexer', 'Coordinator only. Set herdr (default, Windows/Linux) or none (separate visible Windows terminals; unsupported on Linux) for future launches. Never move/restart existing workers or fall back.', Type.Object({ multiplexer: Type.String({ enum: ['herdr', 'none'] }) })],
27
27
  ];
@@ -1,7 +1,8 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
- import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import { truncateToWidth, visibleWidth } from 'pi-intercom-tui';
3
3
  import type { ObservationSnapshot } from './snapshot.js';
4
4
  import { workerObservation } from './worker-status.js';
5
+ import { currentConnection, sortWorkersByConnection } from './connections.js';
5
6
 
6
7
  const safe = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g, ' ');
7
8
  const clip = (value: string, width: number) => stripVTControlCharacters(truncateToWidth(safe(value), Math.max(0, width)));
@@ -50,29 +51,29 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
50
51
  if (!snapshot?.config) {
51
52
  lines.push(paint('33', line(' Status unavailable — waiting for local observations')));
52
53
  } else {
53
- const workers = snapshot.config.agents.filter(agent => !agent.coordinator);
54
- // Report remains distinct from observed status; last activity gives way first.
54
+ const workers = sortWorkersByConnection(snapshot.config.agents.filter(agent => !agent.coordinator), snapshot.connections, now);
55
+ // Connection is independent of historical activity and public reports.
55
56
  const wide = width >= 100;
56
- const nameWidth = wide ? Math.min(22, Math.max(10, width - 88)) : 10;
57
- const activityWidth = wide ? 12 : 8, ageWidth = 16, reportWidth = 16;
58
- const table = (name: string, activity: string, age: string, detail: string, report = '—', tone = '90', selected = false) => {
59
- const prefix = `${selected ? '› ' : ' '}${cell(name, nameWidth)} ${cell(age, ageWidth)} `;
60
- const status = cell(activity, activityWidth);
61
- const tail = ` ${cell(report, reportWidth)}${wide ? ` ${detail}` : ''}`;
62
- // Clip as plain text first; no untrusted escape sequences enter the output.
63
- if (!color) return line(prefix + status + tail);
57
+ const nameWidth = wide ? Math.min(22, Math.max(10, width - 100)) : Math.max(1, Math.min(10, width - 16));
58
+ const activityWidth = 8, ageWidth = 16, reportWidth = 16;
59
+ const table = (name: string, connection: string, activity: string, age: string, detail: string, report = '—', tone = '90', selected = false) => {
60
+ const prefix = `${selected ? '› ' : ' '}${cell(name, nameWidth)} `;
61
+ const status = cell(connection, 12);
62
+ const tail = ` ${cell(report, reportWidth)} ${cell(activity, activityWidth)}${width >= 78 ? ` ${cell(age, ageWidth)}` : ''}${wide ? ` ${detail}` : ''}`;
63
+ // Clip each lower-priority section independently so a truncated tail never
64
+ // steals space from a fully fitting connection label. Styling is trusted.
64
65
  const namePart = clip(prefix, width);
65
66
  const statusRoom = Math.max(0, width - visibleWidth(namePart));
66
67
  const statusPart = clip(status, statusRoom);
67
68
  const tailRoom = Math.max(0, statusRoom - visibleWidth(statusPart));
68
69
  return (selected ? paint('1;36', namePart) : namePart) + paint(tone, statusPart) + dim(clip(tail, tailRoom));
69
70
  };
70
- lines.push(paint('1', table('Worker', 'Status', 'Seen', 'Last activity', 'Report')));
71
+ lines.push(paint('1', table('Worker', 'Connection', 'Activity', 'Seen', 'Last activity', 'Report')));
71
72
  lines.push(dim(line(' ' + '─'.repeat(Math.max(0, width - 2)))));
72
73
  const selectedIndex = Math.min(workers.length - 1, Math.max(-1, options.selectedIndex ?? -1));
73
74
  const showDetails = Boolean(options.details && selectedIndex >= 0 && height >= 8);
74
75
  const selectedReport = snapshot.reports?.find(report => report.sessionId === workers[selectedIndex]?.sessionId && reportLabels[report.status]);
75
- const detailBudget = showDetails ? Math.min(selectedReport ? 12 : 5, height - 5) : 0;
76
+ const detailBudget = showDetails ? Math.min(selectedReport ? 15 : 8, height - 5) : 0;
76
77
  const available = Math.max(0, height - lines.length - 2 - detailBudget);
77
78
  const needsOverflow = workers.length > available;
78
79
  const count = Math.min(workers.length, Math.max(0, available - (needsOverflow && available > 1 ? 1 : 0)));
@@ -82,17 +83,25 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
82
83
  const observation = workerObservation(snapshot, worker.sessionId, now);
83
84
  const activity = observation.observedStatus, evidence = observation.lastActivity;
84
85
  const age = observation.observationAgeSeconds === null ? '—' : ageLabel(observation.observationAgeSeconds * 1000) + (observation.stale ? ' (old)' : '');
85
- const tone = observation.stale ? '90' : activity === 'thinking' ? '35' : activity === 'working' ? '36' : activity === 'idle' ? '32' : '90';
86
+ const connection = currentConnection(worker.sessionId, snapshot.connections, now);
87
+ const tone = connection.state === 'disconnected' ? '33' : connection.state === 'connected' ? '32' : '90';
88
+ const checkedAge = connection.checkedAt ? now - Date.parse(connection.checkedAt) : NaN;
89
+ const connectionDetails = [
90
+ line(` Connection: ${connection.state} · ${connection.reason}`),
91
+ line(` Checked: ${connection.checkedAt ?? 'never'} · ${Number.isFinite(checkedAge) && checkedAge >= 0 ? ageLabel(checkedAge) : 'age unknown'}`),
92
+ ];
86
93
  const selected = start + offset === selectedIndex;
87
94
  const report = snapshot.reports?.find(report => report.sessionId === worker.sessionId && reportLabels[report.status]);
88
- lines.push(table(worker.name, activity, age, evidence, report ? reportLabels[report.status] : '—', tone, selected));
95
+ lines.push(table(worker.name, connection.state, activity, age, evidence, report ? reportLabels[report.status] : '—', tone, selected));
89
96
  if (selected && showDetails && report) {
90
97
  const elapsed = now - Date.parse(report.updatedAt);
91
98
  const reportAge = !Number.isFinite(elapsed) || elapsed < 0 ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
92
- const contextRows = detailBudget >= 5 ? [
93
- ...(detailBudget >= 6 ? [paint('1', line(` ${worker.name}`))] : []),
99
+ const contextRows = detailBudget >= 8 ? [
100
+ paint('1', line(` ${worker.name}`)),
101
+ ...connectionDetails,
94
102
  line(` Responsibility: ${worker.description || 'Not specified'}`),
95
103
  line(` Observed status: ${activity} · ${age}`),
104
+ ...(detailBudget >= 10 ? [line(` Last activity: ${evidence}`)] : []),
96
105
  ] : [];
97
106
  selectedDetail = [
98
107
  ...contextRows,
@@ -101,15 +110,14 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
101
110
  ...wrapSummary(`Summary: ${report.summary}`, Math.max(0, width - 2), Math.max(0, detailBudget - 2 - contextRows.length)).map(text => line(` ${text}`)),
102
111
  ];
103
112
  } else if (selected && showDetails) selectedDetail = [
104
- dim(line(' ' + '─'.repeat(Math.max(0, width - 2)))),
105
- paint('1', line(` ${worker.name}`)),
113
+ ...connectionDetails,
106
114
  line(` Responsibility: ${worker.description || 'Not specified'}`),
107
115
  line(` Observed status: ${activity} · ${age}`),
108
116
  line(` Last activity: ${evidence}`),
109
117
  ];
110
118
  }
111
119
  if (workers.length > count && available > count) lines.push(dim(line(` ${workers.length - count} more · rows ${count ? start + 1 : 0}–${start + count} of ${workers.length} · ↑↓ to browse`)));
112
- lines.push(...(selectedReport ? selectedDetail.slice(0, detailBudget) : selectedDetail.slice(-detailBudget || selectedDetail.length)));
120
+ lines.push(...selectedDetail.slice(0, detailBudget));
113
121
  if (!workers.length && available) lines.push(dim(line(' No workers configured yet')));
114
122
  }
115
123
  // Anchor the hint at the bottom; do not fill the pane with decorative boxes.
package/src/monitor.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { Key, matchesKey, ProcessTerminal, TuiAltScreen, type Component, type TUI } from '@earendil-works/pi-tui';
4
+ import { Key, matchesKey, ProcessTerminal, TuiAltScreen, type Component, type TUI } from 'pi-intercom-tui';
5
5
  import { readObservationSnapshot, type ObservationSnapshot } from './snapshot.js';
6
6
  import { renderMonitor } from './monitor-view.js';
7
+ import { probeWorkers, sortWorkersByConnection, type WorkerConnection } from './connections.js';
7
8
 
8
9
  export function parseMonitorRoot(args: string[]): string {
9
10
  if (args.length !== 2 || args[0] !== '--root' || !path.isAbsolute(args[1])) {
@@ -14,6 +15,8 @@ export function parseMonitorRoot(args: string[]): string {
14
15
 
15
16
  export interface MonitorOptions {
16
17
  read?: typeof readObservationSnapshot;
18
+ /** Injected readers do not probe unless explicitly supplied. */
19
+ probe?: typeof probeWorkers;
17
20
  rows: () => number;
18
21
  requestRender: () => void;
19
22
  onQuit: () => void;
@@ -24,13 +27,24 @@ export interface MonitorOptions {
24
27
  schedule?: (callback: () => void, delayMs: number) => () => void;
25
28
  }
26
29
 
27
- /** No host, probing, network or writes. Closing never awaits an outstanding read. */
30
+ /** Read-only snapshots and bounded health checks. Closing never awaits pending IO. */
28
31
  export function createMonitor(root: string, options: MonitorOptions): Component & { start(): void; close(): void } {
29
32
  let snapshot: ObservationSnapshot | undefined;
30
33
  let selectedIndex = -1;
31
34
  let selectedSessionId: string | undefined;
32
35
  let details = false;
33
- const workers = () => snapshot?.config?.agents.filter(agent => !agent.coordinator) ?? [];
36
+ const now = options.now ?? Date.now;
37
+ const workers = (at = now()) => sortWorkersByConnection(snapshot?.config?.agents.filter(agent => !agent.coordinator) ?? [], snapshot?.connections, at);
38
+ const probe = options.probe ?? (options.read ? undefined : probeWorkers);
39
+ const abort = new AbortController();
40
+ let connections: WorkerConnection[] = [];
41
+ let connectionPorts = new Map<string, number>();
42
+ let probeStart = 0;
43
+ const selection = (at = now()) => {
44
+ const roster = workers(at);
45
+ const index = roster.findIndex(worker => worker.sessionId === selectedSessionId);
46
+ return { roster, index: index >= 0 ? index : selectedIndex };
47
+ };
34
48
  const requestRender = () => { try { options.requestRender(); } catch { /* UI shutdown is independent of reading. */ } };
35
49
  let closed = false, started = false;
36
50
  let cancel: (() => void) | undefined;
@@ -45,6 +59,31 @@ export function createMonitor(root: string, options: MonitorOptions): Component
45
59
  let next: ObservationSnapshot | undefined;
46
60
  try { next = await (options.read ?? readObservationSnapshot)(root); } catch { /* Unavailable, no raw errors. */ }
47
61
  if (closed) return;
62
+ if (next?.config && probe) {
63
+ const roster = next.config.agents.filter(agent => !agent.coordinator);
64
+ const start = roster.length ? probeStart % roster.length : 0;
65
+ const rotated = [...roster.slice(start), ...roster.slice(0, start)].slice(0, 256);
66
+ let checked: WorkerConnection[] = [];
67
+ try { checked = await probe(rotated, { signal: abort.signal }); } catch { /* Preserve prior checks until stale. */ }
68
+ if (closed) return;
69
+ // A check belongs to a saved endpoint, not just a session identity. Keep
70
+ // the last successful roster across read failures, but retire removed IDs
71
+ // and invalidate cached evidence when their configured port changes.
72
+ const ports = new Map(roster.map(worker => [worker.sessionId, worker.port]));
73
+ const previous = new Map(connections
74
+ .filter(connection => ports.has(connection.sessionId) && connectionPorts.get(connection.sessionId) === ports.get(connection.sessionId))
75
+ .map(connection => [connection.sessionId, connection]));
76
+ connectionPorts = ports;
77
+ for (const connection of checked) {
78
+ if (connection.reason !== 'not_checked' || !previous.has(connection.sessionId)) previous.set(connection.sessionId, connection);
79
+ }
80
+ connections = roster.flatMap(worker => {
81
+ const connection = previous.get(worker.sessionId);
82
+ return connection ? [connection] : [];
83
+ });
84
+ probeStart = start + Math.max(1, checked.filter(connection => connection.reason !== 'not_checked').length);
85
+ next = { ...next, connections };
86
+ }
48
87
  snapshot = next;
49
88
  // Preserve identity through reordering, and retain it across transient read failures.
50
89
  if (snapshot?.config) {
@@ -60,6 +99,7 @@ export function createMonitor(root: string, options: MonitorOptions): Component
60
99
  const close = () => {
61
100
  if (closed) return;
62
101
  closed = true;
102
+ abort.abort();
63
103
  cancel?.(); cancel = undefined;
64
104
  };
65
105
  return {
@@ -69,8 +109,9 @@ export function createMonitor(root: string, options: MonitorOptions): Component
69
109
  render(width) {
70
110
  const height = Math.max(0, Math.floor(options.rows()));
71
111
  if (width <= 0 || !height) return [];
72
- const view = { selectedIndex: snapshot?.config ? selectedIndex : -1, details: !!snapshot?.config && details };
73
- try { return renderMonitor(snapshot, width, height, (options.now ?? Date.now)(), options.color, view); }
112
+ const at = now();
113
+ const view = { selectedIndex: snapshot?.config ? selection(at).index : -1, details: !!snapshot?.config && details };
114
+ try { return renderMonitor(snapshot, width, height, at, options.color, view); }
74
115
  catch { return renderMonitor(undefined, width, height, Date.now(), options.color, { selectedIndex: -1, details: false }); }
75
116
  },
76
117
  handleInput(data) {
@@ -81,7 +122,8 @@ export function createMonitor(root: string, options: MonitorOptions): Component
81
122
  if (matchesKey(data, 'q') || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
82
123
  close(); options.onQuit(); return;
83
124
  }
84
- const roster = workers();
125
+ const { roster, index } = selection();
126
+ selectedIndex = index;
85
127
  if (!roster.length) return;
86
128
  if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
87
129
  const next = Math.max(0, Math.min(roster.length - 1, selectedIndex + (matchesKey(data, Key.up) ? -1 : 1)));
package/src/runtime.ts CHANGED
@@ -6,6 +6,7 @@ import { listen, send, reportPayload, type Endpoint, type Envelope, type Kind }
6
6
  import { saveWorkerReport } from './reports.js';
7
7
  import { readObservationSnapshot } from './snapshot.js';
8
8
  import { workerStatusPage } from './worker-status.js';
9
+ import { probeWorker, probeWorkers } from './connections.js';
9
10
 
10
11
  export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and close_worker are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. See references/implementation-blocker.md. A verified supported host API is required (no version-only override).';
11
12
  export interface Host {
@@ -21,6 +22,7 @@ export interface RuntimeOptions {
21
22
  launch(request: LaunchRequest): Promise<unknown>;
22
23
  listen?: typeof listen;
23
24
  send?: typeof send;
25
+ probe?: typeof probeWorker;
24
26
  store?: (root: string) => ConfigStore;
25
27
  observe?: (root: string, sessionId: string) => Observer;
26
28
  }
@@ -35,6 +37,8 @@ export class Intercom {
35
37
  private closing?: Promise<void>;
36
38
  private reportWrites: Promise<void> = Promise.resolve();
37
39
  private pendingReports = 0;
40
+ private probeController = new AbortController();
41
+ private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
38
42
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
39
43
  const observer = this.observer;
40
44
  return (event, metadata = {}) => {
@@ -60,6 +64,8 @@ export class Intercom {
60
64
  if (this.closing) await this.closing;
61
65
  if (this.active) fail('runtime already active');
62
66
  this.initialId = this.host.sessionId(); this.active = true;
67
+ this.probeController = new AbortController();
68
+ this.resumeFences.clear();
63
69
  const generation = ++this.generation, assertValid = this.validity();
64
70
  let record = this.captureObserver();
65
71
  try {
@@ -72,7 +78,10 @@ export class Intercom {
72
78
  const previous = discovered ? await this.store.read() : undefined;
73
79
  assertValid();
74
80
  const own = previous?.agents.find(a => a.sessionId === this.id());
75
- const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
81
+ const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); }, () => {
82
+ assertValid();
83
+ return { version: 1, sessionId: this.initialId };
84
+ });
76
85
  try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
77
86
  this.endpoint = endpoint;
78
87
  if (!previous) {
@@ -118,6 +127,7 @@ export class Intercom {
118
127
  this.recordObservation('runtime.closed');
119
128
  const observer = this.observer; this.observer = undefined;
120
129
  this.active = false; this.generation++;
130
+ this.probeController.abort();
121
131
  const endpoint = this.endpoint; this.endpoint = undefined;
122
132
  this.responsibility = undefined;
123
133
  const closing = (async () => {
@@ -202,6 +212,8 @@ export class Intercom {
202
212
  private async handleReceive(message: Envelope): Promise<void> {
203
213
  const record = this.captureObserver();
204
214
  const assertValid = this.validity();
215
+ const receivedFence = message.kind === 'status' ? this.resumeFences.get(message.from) : undefined;
216
+ const eligibleAnnouncement = receivedFence?.submitted ? receivedFence : undefined;
205
217
  const { id, config, me } = await this.state();
206
218
  assertValid();
207
219
  if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
@@ -233,6 +245,12 @@ export class Intercom {
233
245
  const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
234
246
  if (entry) entry.port = message.payload.port as number;
235
247
  }, assertValid);
248
+ assertValid();
249
+ const fence = sender && this.resumeFences.get(sender.sessionId);
250
+ if (fence && fence === eligibleAnnouncement) {
251
+ fence.announced = true;
252
+ if (!fence.inFlight) this.resumeFences.delete(sender!.sessionId);
253
+ }
236
254
  record('status.received', { ...metadata, busy: message.payload.busy as boolean, port: message.payload.port as number });
237
255
  if (sender) record('config.changed', { operation: 'port_update', peerSessionId: message.from, port: message.payload.port as number, outcome: 'written' });
238
256
  notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
@@ -291,7 +309,14 @@ export class Intercom {
291
309
  if (!me) fail('worker_status requires a configured session');
292
310
  const snapshot = await readObservationSnapshot(this.store.root);
293
311
  assertValid();
294
- return workerStatusPage(snapshot, { name: args.name as string | undefined, offset: args.offset as number | undefined, limit: args.limit as number | undefined });
312
+ if (args.probe !== undefined && typeof args.probe !== 'boolean') fail('probe must be a boolean');
313
+ const pageOptions = { name: args.name as string | undefined, offset: args.offset as number | undefined, limit: args.limit as number | undefined };
314
+ const page = workerStatusPage(snapshot, pageOptions);
315
+ if (!args.probe) return page;
316
+ const ids = new Set(page.workers.map(worker => worker.sessionId));
317
+ snapshot.connections = await probeWorkers(snapshot.config?.agents.filter(agent => !agent.coordinator && ids.has(agent.sessionId)) ?? [], { signal: this.probeController.signal });
318
+ assertValid();
319
+ return workerStatusPage(snapshot, pageOptions);
295
320
  }
296
321
  if (operation === 'list') {
297
322
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
@@ -331,9 +356,31 @@ export class Intercom {
331
356
  return { accepted: true, completion: 'not awaited' };
332
357
  }
333
358
  if (operation === 'resume_worker') {
334
- const relative = await directory(this.store.root, target.projectDirectory);
335
- assertValid();
336
- return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
359
+ if (args.confirmClosed !== true) fail('Resume requires explicit user confirmation that the previous worker session is closed (confirmClosed:true). Disconnected alone is not proof of closure.');
360
+ if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
361
+ const fence = { inFlight: true, submitted: false, announced: false };
362
+ this.resumeFences.set(target.sessionId, fence);
363
+ let completed = false;
364
+ try {
365
+ const connection = await (this.options.probe ?? probeWorker)(target, { signal: this.probeController.signal });
366
+ assertValid();
367
+ if (connection.state === 'connected') fail('Worker endpoint is still connected; refusing to resume the same session twice.');
368
+ const relative = await directory(this.store.root, target.projectDirectory);
369
+ assertValid();
370
+ const cwd = path.resolve(this.store.root, relative);
371
+ const fresh = await this.state();
372
+ assertValid();
373
+ requireCoordinator(fresh.config, id);
374
+ const current = fresh.config.agents.find(agent => agent.sessionId === target.sessionId && !agent.coordinator);
375
+ if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
376
+ fence.submitted = true;
377
+ const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, sessionId: current.sessionId });
378
+ completed = true;
379
+ return result;
380
+ } finally {
381
+ fence.inFlight = false;
382
+ if ((!fence.submitted || (completed && fence.announced)) && this.resumeFences.get(target.sessionId) === fence) this.resumeFences.delete(target.sessionId);
383
+ }
337
384
  }
338
385
  if (operation === 'remove_worker') {
339
386
  // No live process claim: the caller must explicitly ensure closure first.
package/src/snapshot.ts CHANGED
@@ -3,6 +3,7 @@ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { validateConfig, type Config } from './config.js';
5
5
  import { readWorkerReports, type WorkerReport } from './reports.js';
6
+ import type { WorkerConnection } from './connections.js';
6
7
  import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
7
8
 
8
9
  const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
@@ -14,6 +15,8 @@ export interface ObservationSnapshot {
14
15
  config: Pick<Config, 'multiplexer' | 'agents'> | null;
15
16
  events: Observation[];
16
17
  reports?: WorkerReport[];
18
+ /** Ephemeral explicit health checks; never persisted or inferred from activity logs. */
19
+ connections?: WorkerConnection[];
17
20
  truncated: boolean;
18
21
  errors: string[];
19
22
  }