@comput/pi-intercom 0.4.2 → 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/README.md +28 -5
- package/dist/connections.d.ts +29 -0
- package/dist/connections.js +156 -0
- package/dist/connections.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/monitor-view.js +27 -20
- package/dist/monitor-view.js.map +1 -1
- package/dist/monitor.d.ts +4 -1
- package/dist/monitor.js +50 -5
- package/dist/monitor.js.map +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +60 -5
- package/dist/runtime.js.map +1 -1
- package/dist/snapshot.d.ts +3 -0
- package/dist/snapshot.js.map +1 -1
- package/dist/transport.d.ts +4 -1
- package/dist/transport.js +18 -1
- package/dist/transport.js.map +1 -1
- package/dist/worker-status.d.ts +4 -1
- package/dist/worker-status.js +5 -1
- package/dist/worker-status.js.map +1 -1
- package/package.json +2 -2
- package/references/observability.md +16 -5
- package/src/connections.ts +142 -0
- package/src/index.ts +2 -2
- package/src/monitor-view.ts +27 -19
- package/src/monitor.ts +47 -5
- package/src/runtime.ts +52 -5
- package/src/snapshot.ts +3 -0
- package/src/transport.ts +12 -1
- package/src/worker-status.ts +6 -2
|
@@ -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.
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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.
|
|
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
|
];
|
package/src/monitor-view.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { stripVTControlCharacters } from 'node:util';
|
|
|
2
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
|
-
//
|
|
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 -
|
|
57
|
-
const activityWidth =
|
|
58
|
-
const table = (name: string, activity: string, age: string, detail: string, report = '—', tone = '90', selected = false) => {
|
|
59
|
-
const prefix = `${selected ? '› ' : ' '}${cell(name, nameWidth)}
|
|
60
|
-
const status = cell(
|
|
61
|
-
const tail = ` ${cell(report, reportWidth)}${wide ? ` ${detail}` : ''}`;
|
|
62
|
-
// Clip
|
|
63
|
-
|
|
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', '
|
|
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 ?
|
|
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
|
|
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 >=
|
|
93
|
-
|
|
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
|
-
|
|
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(...
|
|
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
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
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
|
-
/**
|
|
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
|
|
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
|
|
73
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
}
|
package/src/transport.ts
CHANGED
|
@@ -35,9 +35,20 @@ export function envelope(value: unknown): Envelope {
|
|
|
35
35
|
return m;
|
|
36
36
|
}
|
|
37
37
|
export interface Endpoint { port: number; close(): Promise<void> }
|
|
38
|
-
export async function listen(preferred: number | undefined, accept: (message: Envelope) => Promise<void
|
|
38
|
+
export async function listen(preferred: number | undefined, accept: (message: Envelope) => Promise<void>, health?: () => { version: 1; sessionId: string }): Promise<Endpoint> {
|
|
39
39
|
const server = http.createServer(async (req, res) => {
|
|
40
40
|
const reply = (status: number, data: unknown) => { if (!res.destroyed) { res.writeHead(status, { 'content-type': 'application/json', connection: 'close' }); res.end(JSON.stringify(data)); } };
|
|
41
|
+
if (req.method === 'GET' && req.url === '/intercom/health') {
|
|
42
|
+
try {
|
|
43
|
+
if (!health) reply(404, { error: 'health unavailable' });
|
|
44
|
+
else {
|
|
45
|
+
const identity = health();
|
|
46
|
+
if (identity.version !== 1 || typeof identity.sessionId !== 'string' || !identity.sessionId.trim() || identity.sessionId.length > 256) throw new Error('invalid health identity');
|
|
47
|
+
reply(200, { version: 1, sessionId: identity.sessionId });
|
|
48
|
+
}
|
|
49
|
+
} catch { reply(503, { error: 'health unavailable' }); }
|
|
50
|
+
req.resume(); return;
|
|
51
|
+
}
|
|
41
52
|
if (req.method !== 'POST' || req.url !== '/intercom') { reply(404, { error: 'POST /intercom required' }); req.resume(); return; }
|
|
42
53
|
let bytes = 0;
|
|
43
54
|
const chunks: Buffer[] = [];
|