@samuelho-dev/coordinator 0.3.0-beta.2 → 0.3.0-beta.3

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.
@@ -9,18 +9,19 @@ class InstallError extends Schema.TaggedError()("InstallError", { message: Schem
9
9
  const installationFailure = () => new InstallError({ message: "Installation failed; check owner, private paths, installed package and destination permissions. Existing native configuration was not changed." });
10
10
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
11
11
  const packageRoot = new URL("../../", import.meta.url);
12
- const help = `Usage: coordinator-install --target pi|omp|claude|codex|hermes --destination /absolute/new-directory [--config /absolute/private/inspector.json] [--runtime /absolute/node] [--dry-run]
12
+ const help = `Usage: coordinator-install --target pi|omp|claude|codex|hermes --destination /absolute/new-directory [--config /absolute/private/inspector.json] [--runtime /absolute/node] [--source] [--dry-run]
13
13
 
14
14
  Run as the native session owner. --destination must be a NEW directory under an existing private owner directory; no existing native files are overwritten.
15
15
  --config selects an existing private connector config; otherwise use COORDINATOR_CONNECTOR_CONFIG, the default private file, or COORDINATOR_API_URL/COORDINATOR_API_TOKEN. --runtime defaults to this Node executable.
16
16
  --dry-run validates prerequisites and prints activation instructions without writing.
17
- Printed native install/link commands activate this exact installed package. Generated include artifacts require separate explicit activation; supervise the external coordinator connector separately. API tokens must be inherited from the native owner's environment/secret manager or read from the private config; they are never embedded in artifacts.
17
+ Printed native install commands pin the published package version. --source instead activates this exact local package (for intentional checkout/tarball development, never a managed native node_modules directory). Generated include artifacts require separate explicit activation; supervise the external coordinator connector separately. API tokens must be inherited from the native owner's environment/secret manager or read from the private config; they are never embedded in artifacts.
18
18
  No settings/profile/statusLine replacement, exporter activation, remote setup, service management or historical data changes. Claude status-line metrics require optional manual composition; the installer does not configure a status line.`;
19
19
  const install = (version) => Command.make("coordinator-install", {
20
20
  target: Flag.String("target").pipe(Flag.optional),
21
21
  destination: Flag.String("destination").pipe(Flag.optional),
22
22
  config: Flag.String("config").pipe(Flag.optional),
23
23
  runtime: Flag.String("runtime").pipe(Flag.optional),
24
+ source: Flag.Boolean("source").pipe(Flag.withDefault(false)),
24
25
  dryRun: Flag.Boolean("dry-run").pipe(Flag.withDefault(false)),
25
26
  }, (flags) => Effect.gen(function* () {
26
27
  const fs = yield* FileSystem.FileSystem;
@@ -87,13 +88,16 @@ const install = (version) => Command.make("coordinator-install", {
87
88
  if (target === "pi" || target === "omp") {
88
89
  const bundle = join(root, `dist/plugins/${target}.js`);
89
90
  yield* fs.access(bundle, { readable: true }).pipe(Effect.mapError(installationFailure));
90
- // ponytail: activate the real package; wrapper absolute entrypoints break native OMP discovery.
91
+ // Published activation must not self-link a native package manager's node_modules tree.
91
92
  if (target === "omp")
92
93
  json("mcp.json", { mcpServers: { coordinator: mcp } });
93
- activation = `${target === "pi" ? "pi install" : "omp plugin link"} ${quote(root)}\n${target === "pi" ? "The extension provides native coordinator_session_list and coordinator_session_inspect tools; no Pi MCP configuration or adapter is required." : `Merge ONLY the coordinator entry from ${join(destination, "mcp.json")} into ~/.omp/agent/mcp.json; preserve all existing entries.`}\nLaunch the native harness with the same configuration environment${environment ? ` and ${environment}` : ""}.`;
94
+ const activationCommand = flags.source
95
+ ? `${target === "pi" ? "pi install" : "omp plugin link"} ${quote(root)}`
96
+ : `${target === "pi" ? "pi install" : "omp plugin install"} ${quote(`${target === "pi" ? "npm:" : ""}@samuelho-dev/coordinator@${version}`)}`;
97
+ activation = `${activationCommand}\n${target === "pi" ? "The extension provides native coordinator_session_list and coordinator_session_inspect tools; no Pi MCP configuration or adapter is required." : `Merge ONLY the coordinator entry from ${join(destination, "mcp.json")} into ~/.omp/agent/mcp.json; preserve all existing entries.`}\nLaunch the native harness with the same configuration environment${environment ? ` and ${environment}` : ""}.`;
94
98
  }
95
99
  else if (target === "claude" || target === "codex") {
96
- const events = ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop", ...(target === "claude" ? ["PostToolUseFailure", "PostModelSwitch", "PreCompact"] : ["Interrupt"])];
100
+ const events = ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop", ...(target === "claude" ? ["PostToolUseFailure", "PostModelSwitch", "PreCompact", "StopFailure", "Elicitation", "ElicitationResult"] : ["Interrupt"])];
97
101
  const hooks = Object.fromEntries(events.map((event) => [event, [{ hooks: [{ type: "command", command: command(`hook ${target}`), timeout: 3 }] }]]));
98
102
  json("hooks/hooks.json", { hooks });
99
103
  if (target === "claude") {
@@ -28,6 +28,11 @@ export const ProcessIdentity = Schema.Struct({
28
28
  ownership: Schema.Literals(["owned", "shared", "unknown"]),
29
29
  });
30
30
  const NativeWorkState = Schema.Struct({ id: Id, state: Schema.Literals(["pending", "active", "completed", "cancelled", "unknown"]) });
31
+ const RexText = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096), Schema.makeFilter((value) => /[\u0000-\u001f\u007f-\u009f]/u.test(value) ? "Control characters are forbidden" : undefined));
32
+ export const ProgramStatus = Schema.Union([
33
+ Schema.Struct({ state: Schema.Literals(["idle", "working", "done", "error"]) }),
34
+ Schema.Struct({ state: Schema.Literal("blocked"), kind: Schema.Literals(["permission", "question", "auth"]) }),
35
+ ]);
31
36
  export const SessionPresence = Schema.Struct({
32
37
  ref: SessionRef,
33
38
  observedAt: Timestamp,
@@ -42,7 +47,8 @@ export const SessionPresence = Schema.Struct({
42
47
  label: Schema.optional(Label), kind: Schema.optional(Id),
43
48
  provider: Schema.optional(Id), model: Schema.optional(Id), profile: Schema.optional(Id), agent: Schema.optional(Id),
44
49
  cwd: Schema.optional(Path), repository: Schema.optional(Path),
45
- rex: Schema.optional(Schema.Struct({ serverId: Schema.optional(Id), blockId: Schema.optional(Id), sessionId: Schema.optional(Id), windowId: Schema.optional(Id), paneId: Schema.optional(Id), terminalId: Schema.optional(Id) })),
50
+ rex: Schema.optional(Schema.Struct({ serverAddress: Schema.optional(RexText), blockId: Schema.optional(RexText), sessionId: Schema.optional(RexText) })),
51
+ programStatus: Schema.optional(ProgramStatus),
46
52
  processes: Schema.optional(bounded(ProcessIdentity, 128)),
47
53
  activity: Schema.optional(Schema.Struct({
48
54
  state: Schema.Literals(["idle", "working", "waiting", "stopped", "unknown"]),
package/dist/src/main.js CHANGED
@@ -7,6 +7,7 @@ import { Mcp } from "./mcp.js";
7
7
  import { Host, cliStdioLayer, serverLayer } from "./platform.js";
8
8
  import { InspectorConfig, InspectionTools, SessionInspectInput } from "./inspection.js";
9
9
  import { NativeError, connectExternal, loadConnectorConfig, resolveNativeConfig, readConnectorToken, readPrivateFile, projectHook, projectClaudeStatus, projectCodexNotification, writeNativeCache, writeHermesHook } from "./native.js";
10
+ import { runSetup } from "./setup.js";
10
11
  const readInput = Effect.gen(function* () {
11
12
  const stdio = yield* Stdio.Stdio;
12
13
  const bytes = new Uint8Array(65536);
@@ -31,8 +32,10 @@ export const cli = (args) => Effect.scoped(Effect.gen(function* () {
31
32
  return yield* Console.log(manifest.version);
32
33
  }
33
34
  if (mode === "help" || mode === "--help") {
34
- return yield* Console.log(JSON.stringify({ commands: ["inspector", "connector", "mcp", "hook claude|codex|hermes", "claude-statusline", "codex-notification", "list", "inspect"], inspectorConfiguration: "COORDINATOR_CONFIG: private inspector server configuration", configuration: "COORDINATOR_API_URL / COORDINATOR_API_TOKEN override COORDINATOR_CONNECTOR_CONFIG or XDG_CONFIG_HOME/coordinator/inspector.json; credentials may also use tokenFile", statusline: "Optional manual composition only; consumes stdin without producing display output", codexNotification: "Version 2 envelope, source attached-app-server, thread/tokenUsage/updated only; does not observe other TUIs" }));
35
+ return yield* Console.log(JSON.stringify({ commands: ["init [--directory PATH] [--owner ID] [--listen-host IP] [--port 8787]", "connect [--endpoint URL] [--token-file PATH] [--config PATH]", "inspector", "connector", "mcp", "hook claude|codex|hermes", "claude-statusline", "codex-notification", "list", "inspect"], inspectorConfiguration: "COORDINATOR_CONFIG: private inspector server configuration", configuration: "COORDINATOR_API_URL / COORDINATOR_API_TOKEN override COORDINATOR_CONNECTOR_CONFIG or XDG_CONFIG_HOME/coordinator/inspector.json; credentials may also use tokenFile", setup: "Run coordinator init --help or coordinator connect --help. Credentials are accepted only by masked prompt, private file, or environment.", statusline: "Optional manual composition only; consumes stdin without producing display output", codexNotification: "Version 2 envelope, source attached-app-server, thread/tokenUsage/updated only; does not observe other TUIs" }));
35
36
  }
37
+ if (mode === "init" || mode === "connect")
38
+ return yield* runSetup(args);
36
39
  if (mode === "inspector") {
37
40
  const file = yield* Config.String("COORDINATOR_CONFIG").pipe(Config.withDefault(""));
38
41
  if (!file)
@@ -6,6 +6,7 @@ import { Stream } from "effect";
6
6
  import { Mcp } from "./mcp.js";
7
7
  import { INSPECTION_LIMIT, ConnectorConfig, ConnectorAnnounce, ConnectorAccepted, ConnectorPoll, ConnectorPollResult, ConnectorResult, ConnectorDisconnect, SessionInspection, createInspection, metric, unavailableMetric, sessionRefKey } from "./inspection.js";
8
8
  import { identifyProcess, observeResources } from "./resources.js";
9
+ import { readRexContext } from "./rex.js";
9
10
  export class NativeError extends Data.TaggedError("NativeError") {
10
11
  }
11
12
  const json = (text) => Effect.try({ try: () => JSON.parse(text), catch: () => new NativeError({ operation: "Invalid native JSON" }) });
@@ -266,7 +267,8 @@ export const projectHook = (harness, machineId, value, at) => Effect.gen(functio
266
267
  const name = event.hook_event_name;
267
268
  const activity = name === "PreCompact" || event.source === "compact" ? { state: "working", compacting: true } : name === "SessionEnd" ? { state: "stopped" } : name === "PermissionRequest" ? { state: "waiting", wait: "approval", approval: "pending", ...(event.tool_name ? { currentToolName: event.tool_name } : {}) } : name === "PreToolUse" ? { state: "working", wait: "tool", ...(event.tool_name ? { currentToolName: event.tool_name } : {}) } : name === "Stop" || name === "Interrupt" ? { state: "idle" } : name === "UserPromptSubmit" ? { state: "working", wait: "api" } : { state: "unknown" };
268
269
  const model = event.to_model ?? event.model;
269
- const presence = { ref: { machineId, harness, nativeSessionId: event.session_id }, observedAt: at, nativeUpdatedAt: at, lastActivityAt: at, activity, ...(event.cwd ? { cwd: event.cwd } : {}), ...(model ? { model } : {}) };
270
+ const programStatus = name === "PermissionRequest" ? { state: "blocked", kind: "permission" } : harness === "claude" && name === "Elicitation" ? { state: "blocked", kind: "question" } : name === "Interrupt" ? { state: "idle" } : harness === "claude" && name === "StopFailure" ? { state: "error" } : harness === "claude" && name === "Stop" ? { state: "done" } : activity.state === "working" || (harness === "claude" && (name === "PostToolUse" || name === "PostToolUseFailure" || name === "ElicitationResult")) ? { state: "working" } : undefined;
271
+ const presence = { ref: { machineId, harness, nativeSessionId: event.session_id }, observedAt: at, nativeUpdatedAt: at, lastActivityAt: at, activity, ...(programStatus ? { programStatus } : {}), ...(event.cwd ? { cwd: event.cwd } : {}), ...(model ? { model } : {}) };
270
272
  return createInspection(presence, at);
271
273
  });
272
274
  export const projectClaudeStatus = (machineId, value, at) => Effect.gen(function* () {
@@ -355,6 +357,9 @@ const cacheRecords = (config, hermesIdentities = new Map()) => Effect.gen(functi
355
357
  return records;
356
358
  }).pipe(sanitizeFileFinalizers("Read private metadata cache"), Effect.catchTag("PlatformError", () => Effect.fail(new NativeError({ operation: "Read private metadata cache" }))));
357
359
  export const writeNativeCache = (config, incoming) => Effect.gen(function* () {
360
+ const rex = yield* readRexContext;
361
+ if (rex)
362
+ incoming = { ...incoming, presence: { ...incoming.presence, rex } };
358
363
  const records = yield* cacheRecords(config);
359
364
  const target = yield* cacheFile(config, incoming.presence.ref);
360
365
  if (records.size >= 1024 && !records.has(target))
@@ -364,7 +369,8 @@ export const writeNativeCache = (config, incoming) => Effect.gen(function* () {
364
369
  if (previous !== undefined) {
365
370
  const old = previous.inspection;
366
371
  const metadataOnly = incoming.presence.nativeUpdatedAt !== undefined;
367
- inspection = { ...(metadataOnly ? old : incoming), presence: { ...old.presence, ...incoming.presence }, queriedAt: incoming.queriedAt, context: metadataOnly && !incoming.presence.activity?.compacting ? old.context : incoming.context };
372
+ const { programStatus: previousStatus, ...oldPresence } = old.presence;
373
+ inspection = { ...(metadataOnly ? old : incoming), presence: { ...oldPresence, ...(!incoming.presence.activity && previousStatus ? { programStatus: previousStatus } : {}), ...incoming.presence }, queriedAt: incoming.queriedAt, context: metadataOnly && !incoming.presence.activity?.compacting ? old.context : incoming.context };
368
374
  }
369
375
  const fs = yield* FileSystem.FileSystem;
370
376
  const path = yield* Path.Path;
@@ -458,7 +464,8 @@ export const claudeRoster = (config) => Effect.scoped(Effect.gen(function* () {
458
464
  const identity = yield* identifyProcess(row.pid);
459
465
  if (identity.status !== "available")
460
466
  continue;
461
- sessions.push({ ref: { machineId: config.machineId, harness: "claude", nativeSessionId: id }, observedAt: yield* Clock.currentTimeMillis, ...(row.cwd ? { cwd: row.cwd } : {}), ...(row.kind ? { kind: row.kind } : {}), processes: [{ pid: row.pid, startIdentity: identity.startTime, ownership: "owned" }], activity: { state: row.status === "busy" ? "working" : row.status === "waiting" ? "waiting" : row.status === "idle" ? "idle" : "unknown", ...(row.waitingFor === "permission prompt" ? { wait: "approval", approval: "pending" } : {}) } });
467
+ const programStatus = row.waitingFor === "permission prompt" ? { state: "blocked", kind: "permission" } : row.status === "busy" ? { state: "working" } : row.status === "idle" ? { state: "idle" } : undefined;
468
+ sessions.push({ ref: { machineId: config.machineId, harness: "claude", nativeSessionId: id }, observedAt: yield* Clock.currentTimeMillis, ...(row.cwd ? { cwd: row.cwd } : {}), ...(row.kind ? { kind: row.kind } : {}), ...(programStatus ? { programStatus } : {}), processes: [{ pid: row.pid, startIdentity: identity.startTime, ownership: "owned" }], activity: { state: row.status === "busy" ? "working" : row.status === "waiting" ? "waiting" : row.status === "idle" ? "idle" : "unknown", ...(row.waitingFor === "permission prompt" ? { wait: "approval", approval: "pending" } : {}) } });
462
469
  }
463
470
  return sessions;
464
471
  })).pipe(Effect.timeout("5 seconds"));
@@ -1,10 +1,11 @@
1
1
  import { NodeServices } from "@effect/platform-node";
2
- import { Cause, Clock, Config, Console, Effect, Layer, Logger, ManagedRuntime, Schema } from "effect";
2
+ import { Cause, Clock, Console, Effect, Layer, Logger, ManagedRuntime, Schema } from "effect";
3
3
  import { createInspection, HttpError, InspectionTools, metric, sessionRefKey, unavailableMetric } from "./inspection.js";
4
4
  import { Mcp } from "./mcp.js";
5
5
  import { connectNative, loadConnectorConfig, NativeError, readConnectorToken, resolveNativeConfig, withProcessResources } from "./native.js";
6
6
  import { identifyProcess } from "./resources.js";
7
7
  import { Host as PlatformHost } from "./platform.js";
8
+ import { createRexReporter, readRexContext } from "./rex.js";
8
9
  const provenance = (source, scope, scopeDescription, observedAt, nativeUpdatedAt = null) => ({
9
10
  source, scope, scopeDescription, observedAt, nativeUpdatedAt, estimated: false, derived: false, shared: false, partial: false, stale: false,
10
11
  });
@@ -12,8 +13,10 @@ const provenance = (source, scope, scopeDescription, observedAt, nativeUpdatedAt
12
13
  export const createNativeObserver = (harness, machineId, processes = []) => Effect.gen(function* () {
13
14
  const host = yield* PlatformHost;
14
15
  const clock = yield* Clock.Clock;
15
- const rexSession = yield* Config.String("REX_SESSION").pipe(Config.withDefault(""));
16
- const rexBlock = yield* Config.String("REX_BLOCK").pipe(Config.withDefault(""));
16
+ const rex = yield* readRexContext;
17
+ let programStatus;
18
+ let userPrompt;
19
+ let stopReason;
17
20
  let current;
18
21
  let identity;
19
22
  let lastActivityAt;
@@ -25,6 +28,9 @@ export const createNativeObserver = (harness, machineId, processes = []) => Effe
25
28
  const key = JSON.stringify([ctx.sessionManager.getSessionId(), harness === "omp" ? ctx.agent?.id : undefined]);
26
29
  if (identity !== key) {
27
30
  latest = undefined;
31
+ programStatus = undefined;
32
+ userPrompt = undefined;
33
+ stopReason = undefined;
28
34
  activeTools.clear();
29
35
  identity = key;
30
36
  }
@@ -36,6 +42,33 @@ export const createNativeObserver = (harness, machineId, processes = []) => Effe
36
42
  activeTools.delete(event.toolCallId);
37
43
  if (event?.type === "agent_end")
38
44
  activeTools.clear();
45
+ if (event?.type === "agent_start") {
46
+ programStatus = { state: "working" };
47
+ stopReason = undefined;
48
+ }
49
+ if (event?.type === "turn_start") {
50
+ programStatus = { state: "working" };
51
+ stopReason = undefined;
52
+ }
53
+ if (event?.type === "tool_execution_start")
54
+ programStatus = { state: "working" };
55
+ if (event?.message?.role === "assistant" && event.message.stopReason)
56
+ stopReason = event.message.stopReason;
57
+ if (event?.type === "agent_end")
58
+ stopReason = event.messages?.findLast((message) => message.role === "assistant")?.stopReason ?? stopReason;
59
+ if (harness === "pi" && event?.type === "agent_before_settle")
60
+ stopReason = event.outcome === "completed" ? "stop" : event.outcome;
61
+ if (event?.type === "agent_settled" || (harness !== "pi" && event?.type === "agent_end")) {
62
+ programStatus = event.willContinue ? { state: "working" } : stopReason === "aborted" ? { state: "idle" } : stopReason === "error" ? { state: "error" } : stopReason === "stop" || stopReason === "length" ? { state: "done" } : undefined;
63
+ }
64
+ else if (event?.type === "agent_end")
65
+ programStatus = { state: "working" };
66
+ if (harness === "pi" && event?.type === "ui_prompt_start")
67
+ userPrompt = { state: "blocked", kind: "question" };
68
+ if (harness === "pi" && event?.type === "ui_prompt_end")
69
+ userPrompt = undefined;
70
+ if ((event === undefined || event.type === "session_start" || event.type === "session_switch") && programStatus === undefined)
71
+ programStatus = { state: ctx.isIdle() ? "idle" : "working" };
39
72
  const message = event?.message;
40
73
  if (message?.role === "assistant" && message.usage) {
41
74
  const u = message.usage;
@@ -51,15 +84,18 @@ export const createNativeObserver = (harness, machineId, processes = []) => Effe
51
84
  if (!current)
52
85
  return undefined;
53
86
  const currentToolName = activeTools.values().next().value;
87
+ const status = userPrompt ?? programStatus;
54
88
  return {
55
89
  ref: { machineId, harness, nativeSessionId: current.sessionManager.getSessionId(), ...(harness === "omp" && current.agent?.id ? { nativeAgentId: current.agent.id } : {}) },
56
90
  observedAt: now, ...(lastActivityAt === undefined ? {} : { lastActivityAt }), cwd: current.cwd, processes,
57
91
  machineName, ...(os ? { os } : {}),
58
- ...(rexSession || rexBlock ? { rex: { ...(rexSession ? { sessionId: rexSession } : {}), ...(rexBlock ? { blockId: rexBlock } : {}) } } : {}),
92
+ ...(rex ? { rex } : {}),
93
+ ...(status ? { programStatus: status } : {}),
59
94
  ...(current.model ? { provider: current.model.provider, model: current.model.id } : {}),
60
95
  ...(current.agent ? { kind: current.agent.kind, ...(current.agent.name ? { agent: current.agent.name } : {}) } : {}),
61
- activity: { state: current.isIdle() ? "idle" : "working", streaming: !current.isIdle(),
62
- ...(currentToolName === undefined ? {} : { currentToolName, wait: "tool" }),
96
+ activity: { state: userPrompt ? "waiting" : current.isIdle() ? "idle" : "working", streaming: !current.isIdle(),
97
+ ...(currentToolName === undefined ? {} : { currentToolName }),
98
+ ...(userPrompt ? { wait: "user" } : currentToolName === undefined ? {} : { wait: "tool" }),
63
99
  },
64
100
  };
65
101
  };
@@ -146,7 +182,7 @@ export const createNativeObserver = (harness, machineId, processes = []) => Effe
146
182
  },
147
183
  };
148
184
  };
149
- return { update, presence, inspect, clear: () => { current = undefined; identity = undefined; latest = undefined; lastActivityAt = undefined; activeTools.clear(); } };
185
+ return { update, presence, inspect, clear: () => { current = undefined; identity = undefined; latest = undefined; programStatus = undefined; userPrompt = undefined; stopReason = undefined; lastActivityAt = undefined; activeTools.clear(); } };
150
186
  });
151
187
  export const isCoordinateInput = (text) => /^coordinate(?:\s|$)/u.test(text);
152
188
  export const transformCoordinateInput = (harness, input, snapshot) => {
@@ -161,6 +197,23 @@ export const registerCoordinateKeyword = Effect.gen(function* () {
161
197
  // ponytail: native exposes no registry getter/append; only known core keywords can be preserved.
162
198
  setMagicKeywords([...MAGIC_KEYWORDS.filter(({ word }) => isMagicKeyword(word)), { word: "coordinate", hue: [190, 260] }]);
163
199
  });
200
+ const formatSessionList = (sessions) => {
201
+ if (sessions.length === 0)
202
+ return "No sessions are currently visible to this API key.";
203
+ const lines = [`Coordinator: ${sessions.length} sessions visible to this API key.`, "", "~~~text"];
204
+ for (const session of sessions) {
205
+ lines.push(`Machine: ${JSON.stringify(session.machineName ?? "unavailable")} (${JSON.stringify(session.ref.machineId)})`, `Session: ${session.ref.harness} ${JSON.stringify(session.ref.nativeSessionId)}`);
206
+ if (session.ref.nativeAgentId !== undefined)
207
+ lines.push(`Agent ID: ${JSON.stringify(session.ref.nativeAgentId)}`);
208
+ for (const [field, value] of Object.entries(session)) {
209
+ if (field !== "ref" && field !== "machineName" && value !== undefined)
210
+ lines.push(`${field}: ${JSON.stringify(value)}`);
211
+ }
212
+ lines.push("");
213
+ }
214
+ lines.push("~~~");
215
+ return lines.join("\n");
216
+ };
164
217
  export default function plugin(host) {
165
218
  const harness = COORDINATOR_HARNESS;
166
219
  const runtime = ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, PlatformHost.layer, Mcp.layer, Layer.succeed(Logger.LogToStderr, true)));
@@ -168,6 +221,50 @@ export default function plugin(host) {
168
221
  let observer;
169
222
  let starting;
170
223
  let stopped = false;
224
+ // Stock OMP owns OSC 7501 (terminal.programStatus); never duplicate or override it.
225
+ const rexReporter = harness === "pi" ? createRexReporter((sequence) => process.stdout.write(sequence)) : undefined;
226
+ let terminal = false;
227
+ host.registerCommand("coordinator", {
228
+ description: "List visible sessions, their machines, and native metadata",
229
+ handler: async (args) => {
230
+ if (args.trim() !== "list") {
231
+ host.sendMessage({ customType: "coordinator-list", content: "Usage: /coordinator list", display: true }, { triggerTurn: false });
232
+ return;
233
+ }
234
+ const exit = await runtime.runPromiseExit(Effect.gen(function* () {
235
+ const activeConfig = config ?? (yield* loadConnectorConfig());
236
+ if (!activeConfig)
237
+ return yield* new NativeError({ operation: "Connector configuration required" });
238
+ const token = yield* readConnectorToken(activeConfig);
239
+ const mcp = yield* Mcp;
240
+ const sessions = [];
241
+ let cursor;
242
+ do {
243
+ const page = yield* mcp.call(activeConfig.inspectorUrl, token, InspectionTools.list, { limit: 100, ...(cursor === undefined ? {} : { cursor }) });
244
+ for (const session of page.sessions)
245
+ sessions.push(session);
246
+ cursor = page.nextCursor ?? undefined;
247
+ } while (cursor !== undefined);
248
+ return sessions;
249
+ }));
250
+ if (stopped)
251
+ return;
252
+ if (exit._tag === "Failure") {
253
+ const error = Cause.findErrorOption(exit.cause);
254
+ const reason = error._tag === "Some" ? error.value._tag === "HttpError" ? `HTTP ${error.value.status}` : error.value._tag : "unexpected failure";
255
+ host.sendMessage({
256
+ customType: "coordinator-list",
257
+ content: `Coordinator list unavailable (${reason}). Check inspectorUrl, your access token, and API reachability.`,
258
+ display: true,
259
+ }, { triggerTurn: false });
260
+ return;
261
+ }
262
+ host.sendMessage({
263
+ customType: "coordinator-list", content: formatSessionList(exit.value), display: true,
264
+ details: { sessions: exit.value },
265
+ }, { triggerTurn: false });
266
+ },
267
+ });
171
268
  if (harness === "pi") {
172
269
  const register = (descriptor) => {
173
270
  const document = Schema.toJsonSchemaDocument(Schema.toCodecJson(descriptor.input), { onExcessProperty: "error" });
@@ -229,6 +326,8 @@ export default function plugin(host) {
229
326
  const processIdentity = yield* identifyProcess(host.pid);
230
327
  observer = yield* createNativeObserver(harness, nativeConfig.machineId, processIdentity.status === "available" ? [{ pid: host.pid, startIdentity: processIdentity.startTime, ownership: harness === "omp" ? "shared" : "owned" }] : []);
231
328
  observer.update(ctx);
329
+ terminal = ctx.mode === "tui" && ctx.hasUI && process.stdout.isTTY === true;
330
+ rexReporter?.update(observer.presence()?.programStatus, terminal);
232
331
  // Native TUI constructors register core keywords before session_start hooks.
233
332
  if (harness === "omp" && ctx.hasUI && ctx.mode === "tui")
234
333
  yield* registerCoordinateKeyword;
@@ -253,6 +352,8 @@ export default function plugin(host) {
253
352
  }
254
353
  });
255
354
  const events = ["session_start", "session_switch", "agent_start", "agent_end", "turn_start", "turn_end", "message_end", "tool_execution_start", "tool_execution_end"];
355
+ if (harness === "pi")
356
+ events.push("agent_before_settle", "agent_settled", "ui_prompt_start", "ui_prompt_end");
256
357
  for (const name of events) {
257
358
  host.on(name, async (event, ctx) => {
258
359
  if (stopped)
@@ -260,11 +361,18 @@ export default function plugin(host) {
260
361
  await start(ctx);
261
362
  if (stopped || !observer)
262
363
  return;
263
- await runtime.runPromiseExit(Effect.sync(() => observer?.update(ctx, event)).pipe(Effect.tapCause(reportCause(ctx, "native update"))));
364
+ await runtime.runPromiseExit(Effect.sync(() => {
365
+ if (name === "session_switch")
366
+ rexReporter?.update(undefined, terminal);
367
+ observer?.update(ctx, event);
368
+ terminal = ctx.mode === "tui" && ctx.hasUI && process.stdout.isTTY === true;
369
+ rexReporter?.update(observer?.presence()?.programStatus, terminal);
370
+ }).pipe(Effect.tapCause(reportCause(ctx, "native update"))));
264
371
  });
265
372
  }
266
373
  host.on("session_shutdown", async () => {
267
374
  stopped = true;
375
+ rexReporter?.close(terminal);
268
376
  try {
269
377
  await runtime.dispose();
270
378
  }
@@ -0,0 +1,36 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Config, Effect } from "effect";
3
+ export const readRexContext = Effect.gen(function* () {
4
+ const serverAddress = yield* Config.String("REX_SERVER").pipe(Config.withDefault(""));
5
+ const sessionId = yield* Config.String("REX_SESSION").pipe(Config.withDefault(""));
6
+ const blockId = yield* Config.String("REX_BLOCK").pipe(Config.withDefault(""));
7
+ return serverAddress || sessionId || blockId ? {
8
+ ...(serverAddress ? { serverAddress } : {}), ...(sessionId ? { sessionId } : {}), ...(blockId ? { blockId } : {}),
9
+ } : undefined;
10
+ });
11
+ // ponytail: fixed labels avoid leaking native content and stay below all OSC text limits.
12
+ export const rexSequence = (id, status) => {
13
+ if (id.length > 128 || id.split("/").length > 8 || !/^[A-Za-z0-9_.+-]{1,32}(?:\/[A-Za-z0-9_.+-]{1,32})*$/u.test(id))
14
+ return "";
15
+ return `\u001b]7501;id=${id}:state=${status?.state ?? "clear"}:app=coordinator:title=Q29vcmRpbmF0b3I=${status?.state === "blocked" ? `:kind=${status.kind}` : ""}\u001b\\`;
16
+ };
17
+ export const createRexReporter = (write) => {
18
+ const id = `coordinator/${randomUUID().replaceAll("-", "")}`;
19
+ let previous;
20
+ return {
21
+ update: (status, terminal) => {
22
+ if (!terminal)
23
+ return;
24
+ const sequence = rexSequence(id, status);
25
+ if (previous === sequence || (previous === undefined && status === undefined))
26
+ return;
27
+ write(sequence);
28
+ previous = sequence;
29
+ },
30
+ close: (terminal) => {
31
+ if (terminal && previous !== undefined && !/:state=(?:done|error):/u.test(previous))
32
+ write(rexSequence(id, undefined));
33
+ previous = undefined;
34
+ },
35
+ };
36
+ };
@@ -0,0 +1,134 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, realpath, rmdir, unlink } from "node:fs/promises";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { isIP } from "node:net";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { Config, Console, Effect, Option, Redacted, Schema } from "effect";
7
+ import { Command, Flag, Prompt } from "effect/cli";
8
+ import { ConnectorConfig, InspectionTools, InspectorConfig } from "./inspection.js";
9
+ import { Mcp } from "./mcp.js";
10
+ import { NativeError, readConnectorToken } from "./native.js";
11
+ import { Host } from "./platform.js";
12
+ const failure = (operation) => () => new NativeError({ operation });
13
+ const privateFailure = failure("Setup could not write private files; use a canonical owner-controlled path, mode-0700 directory and new filenames. Existing files are never overwritten");
14
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
15
+ const canonical = (file) => {
16
+ if (!isAbsolute(file) || resolve(file) !== file || /[\x00-\x1f\x7f-\x9f]/u.test(file))
17
+ throw new Error("Unsafe path");
18
+ };
19
+ // Exclusive creation is the overwrite fence; remove only files this transaction created.
20
+ const writePrivate = (directory, files, uid) => Effect.tryPromise({
21
+ try: async () => {
22
+ canonical(directory);
23
+ if (uid === undefined)
24
+ throw new Error("Owner unavailable");
25
+ const createdDirectories = [], createdFiles = [];
26
+ const ensure = async (target, privateDirectory) => {
27
+ try {
28
+ const info = await lstat(target);
29
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(target) !== target || (privateDirectory && (info.uid !== uid || (info.mode & 0o777) !== 0o700)))
30
+ throw new Error("Unsafe directory");
31
+ // Ancestors must not be replaceable by another owner; sticky /tmp is allowed.
32
+ if (!privateDirectory && (info.uid !== uid && info.uid !== 0 || (info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0))
33
+ throw new Error("Unsafe ancestor");
34
+ }
35
+ catch (error) {
36
+ if (typeof error !== "object" || error === null || !("code" in error) || error.code !== "ENOENT")
37
+ throw error;
38
+ await ensure(dirname(target), false);
39
+ await mkdir(target, { mode: 0o700 });
40
+ createdDirectories.push(target);
41
+ await chmod(target, 0o700);
42
+ }
43
+ };
44
+ try {
45
+ await ensure(directory, true);
46
+ // realpath rejects symlinks anywhere in existing ancestry; validate its writable ancestors too.
47
+ for (let parent = dirname(directory);; parent = dirname(parent)) {
48
+ await ensure(parent, false);
49
+ if (parent === dirname(parent))
50
+ break;
51
+ }
52
+ for (const [file, content] of files) {
53
+ canonical(file);
54
+ if (dirname(file) !== directory)
55
+ throw new Error("Unsafe destination");
56
+ const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
57
+ createdFiles.push(file);
58
+ try {
59
+ await handle.chmod(0o600);
60
+ await handle.writeFile(content, "utf8");
61
+ }
62
+ finally {
63
+ await handle.close();
64
+ }
65
+ }
66
+ }
67
+ catch (error) {
68
+ for (const file of createdFiles.reverse())
69
+ await unlink(file).catch(() => undefined);
70
+ for (const dir of createdDirectories.reverse())
71
+ await rmdir(dir).catch(() => undefined);
72
+ throw error;
73
+ }
74
+ },
75
+ catch: privateFailure,
76
+ });
77
+ const configHome = Effect.gen(function* () {
78
+ const host = yield* Host;
79
+ const xdg = yield* Config.String("XDG_CONFIG_HOME").pipe(Config.option);
80
+ return join(Option.getOrUndefined(xdg) ?? join(host.homeDirectory, ".config"), "coordinator");
81
+ });
82
+ export const initializeServer = (options) => Effect.gen(function* () {
83
+ if ((!isIP(options.host) && options.host !== "localhost") || !Number.isInteger(options.port) || options.port < 1 || options.port > 65535 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(options.owner)) {
84
+ return yield* new NativeError({ operation: "Init requires an IP address or localhost, port 1–65535, and an owner identifier containing only letters, digits, dot, underscore or hyphen" });
85
+ }
86
+ const token = yield* Effect.try({ try: () => randomBytes(32).toString("base64url"), catch: failure("Secure credential generation unavailable") });
87
+ const config = yield* Schema.decodeEffect(InspectorConfig)({ host: options.host, port: options.port, principals: [{ id: options.owner, owner: options.owner, tokenHash: createHash("sha256").update(token).digest("hex") }] }).pipe(Effect.mapError(failure("Invalid server configuration")));
88
+ const configFile = join(options.directory, "server.json"), tokenFile = join(options.directory, "owner.token");
89
+ yield* writePrivate(options.directory, [[tokenFile, `${token}\n`], [configFile, `${JSON.stringify(config, null, 2)}\n`]], (yield* Host).uid);
90
+ return { configFile, tokenFile };
91
+ });
92
+ export const connectClient = (options) => Effect.gen(function* () {
93
+ const targetToken = options.tokenFile ?? join(dirname(options.configFile), "inspector.token");
94
+ const config = yield* Schema.decodeEffect(ConnectorConfig)({ inspectorUrl: options.endpoint, apiToken: options.token, tokenFile: targetToken }).pipe(Effect.mapError(failure("Invalid endpoint or credential; use HTTPS except for loopback, without URL credentials, query or fragment")));
95
+ if (options.tokenFile !== undefined) {
96
+ const stored = yield* readConnectorToken({ inspectorUrl: options.endpoint, tokenFile: options.tokenFile });
97
+ if (stored !== options.token)
98
+ return yield* new NativeError({ operation: "Credential file changed; retry connection setup" });
99
+ }
100
+ // This is an authenticated read only: it never resolves/creates a native machine identity.
101
+ yield* (yield* Mcp).call(config.inspectorUrl, options.token, InspectionTools.list, { limit: 1 }).pipe(Effect.mapError(failure("Connection check failed; verify the endpoint, TLS certificate and owner credential. No client configuration was written")));
102
+ const files = [];
103
+ if (options.tokenFile === undefined)
104
+ files.push([targetToken, `${options.token}\n`]);
105
+ files.push([options.configFile, `${JSON.stringify({ inspectorUrl: config.inspectorUrl, tokenFile: targetToken }, null, 2)}\n`]);
106
+ yield* writePrivate(dirname(options.configFile), files, (yield* Host).uid);
107
+ return { configFile: options.configFile, tokenFile: targetToken };
108
+ });
109
+ const init = Command.make("init", {
110
+ directory: Flag.String("directory").pipe(Flag.optional),
111
+ owner: Flag.String("owner").pipe(Flag.withDefault("owner")),
112
+ host: Flag.String("listen-host").pipe(Flag.withDefault("127.0.0.1")),
113
+ port: Flag.Int("port").pipe(Flag.withDefault(8787)),
114
+ }, (flags) => Effect.gen(function* () {
115
+ const directory = Option.getOrUndefined(flags.directory) ?? join(yield* configHome, "server");
116
+ const result = yield* initializeServer({ ...flags, directory });
117
+ yield* Console.log(`Created private server configuration: ${result.configFile}\nOwner credential file: ${result.tokenFile}\nStart server:\nCOORDINATOR_CONFIG=${quote(result.configFile)} coordinator inspector\nKeep the credential private. For remote clients, expose this server through HTTPS.`);
118
+ })).pipe(Command.withDescription("Bootstrap a self-hosted server without printing its owner credential. Never overwrites existing files."));
119
+ const connect = Command.make("connect", {
120
+ endpoint: Flag.String("endpoint").pipe(Flag.optional),
121
+ tokenFile: Flag.String("token-file").pipe(Flag.optional),
122
+ config: Flag.String("config").pipe(Flag.optional),
123
+ }, (flags) => Effect.gen(function* () {
124
+ const selected = yield* Config.String("COORDINATOR_CONNECTOR_CONFIG").pipe(Config.option);
125
+ const configFile = Option.getOrUndefined(flags.config) ?? Option.getOrUndefined(selected) ?? join(yield* configHome, "inspector.json");
126
+ const endpoint = Option.getOrUndefined(flags.endpoint) ?? (yield* Prompt.String({ message: "Coordinator endpoint (HTTPS, or loopback HTTP)" }));
127
+ const tokenFile = Option.getOrUndefined(flags.tokenFile);
128
+ const environment = yield* Config.String("COORDINATOR_API_TOKEN").pipe(Config.option);
129
+ const token = tokenFile !== undefined ? yield* readConnectorToken({ inspectorUrl: endpoint, tokenFile })
130
+ : Option.isSome(environment) ? environment.value : Redacted.value(yield* Prompt.Password({ message: "Owner credential" }));
131
+ const result = yield* connectClient({ endpoint, token, configFile, ...(tokenFile === undefined ? {} : { tokenFile }) });
132
+ yield* Console.log(`Authenticated connection saved: ${result.configFile}\nCredential file: ${result.tokenFile}\nClient configuration contains only the endpoint and credential-file pointer. Existing native settings are unchanged.\nCOORDINATOR_API_URL / COORDINATOR_API_TOKEN environment overrides still take precedence at runtime.`);
133
+ })).pipe(Command.withDescription("Authenticate and save a private client configuration. Credentials: --token-file, COORDINATOR_API_TOKEN, or masked prompt; never argv. Existing files are not overwritten."));
134
+ export const runSetup = (args) => Command.runWith(Command.make("coordinator").pipe(Command.withSubcommands([init, connect])), { version: "", renderErrors: false })(args).pipe(Effect.catchTag("ShowHelp", () => Effect.fail(new NativeError({ operation: "Invalid setup flags. Run coordinator init --help or coordinator connect --help. Credentials are never accepted as arguments" }))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samuelho-dev/coordinator",
3
- "version": "0.3.0-beta.2",
3
+ "version": "0.3.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Read-only native agent session inspection across self-hosted machines",
6
6
  "license": "MIT",