@cydm/happy-elves 0.1.0-beta.47 → 0.1.0-beta.49

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.
Files changed (55) hide show
  1. package/apps/cli/dist/commands/app.js +5 -1
  2. package/apps/cli/dist/commands/config.js +2 -2
  3. package/apps/cli/dist/commands/lib/args.js +7 -0
  4. package/apps/cli/dist/commands/lib/config.d.ts +1 -0
  5. package/apps/cli/dist/commands/lib/config.js +15 -2
  6. package/apps/cli/dist/commands/lib/doctor.js +3 -3
  7. package/apps/cli/dist/commands/lib/exit.js +2 -1
  8. package/apps/cli/dist/commands/lib/index.d.ts +1 -0
  9. package/apps/cli/dist/commands/lib/index.js +1 -0
  10. package/apps/cli/dist/commands/lib/json.d.ts +1 -1
  11. package/apps/cli/dist/commands/lib/json.js +2 -2
  12. package/apps/cli/dist/commands/lib/orchestrator.d.ts +30 -0
  13. package/apps/cli/dist/commands/lib/orchestrator.js +146 -0
  14. package/apps/cli/dist/commands/lib/relay-http.js +15 -4
  15. package/apps/cli/dist/commands/lib/session-output.d.ts +1 -0
  16. package/apps/cli/dist/commands/lib/session-output.js +42 -10
  17. package/apps/cli/dist/commands/lib/status.js +3 -3
  18. package/apps/cli/dist/commands/lib/types.d.ts +1 -0
  19. package/apps/cli/dist/commands/lib/usage.js +46 -16
  20. package/apps/cli/dist/commands/orchestrator.d.ts +2 -0
  21. package/apps/cli/dist/commands/orchestrator.js +411 -0
  22. package/apps/cli/dist/commands/relay.js +5 -5
  23. package/apps/cli/dist/commands/remote.js +2 -2
  24. package/apps/cli/dist/commands/skill.d.ts +2 -0
  25. package/apps/cli/dist/commands/skill.js +214 -0
  26. package/apps/cli/dist/commands/turn.js +5 -2
  27. package/apps/daemon/dist/session/directory.d.ts +4 -0
  28. package/apps/daemon/dist/session/directory.js +30 -3
  29. package/apps/daemon/package.json +1 -1
  30. package/apps/relay/dist/connections.d.ts +1 -0
  31. package/apps/relay/dist/controller-handlers.js +4 -1
  32. package/apps/relay/dist/http-routes.js +32 -1
  33. package/apps/relay/dist/http-schemas.d.ts +16 -0
  34. package/apps/relay/dist/http-schemas.js +5 -0
  35. package/apps/relay/dist/relay-context.js +2 -0
  36. package/apps/relay/dist/session-projection-reducer.js +1 -1
  37. package/npm-shrinkwrap.json +5 -5
  38. package/package.json +1 -1
  39. package/packages/client/dist/client.d.ts +5 -0
  40. package/packages/client/dist/client.js +35 -7
  41. package/packages/client/dist/http.d.ts +1 -0
  42. package/packages/client/dist/http.js +15 -3
  43. package/packages/client/dist/live-session-state.d.ts +15 -0
  44. package/packages/client/dist/live-session-state.js +60 -0
  45. package/packages/client/dist/live-state.d.ts +8 -0
  46. package/packages/client/dist/live-state.js +33 -0
  47. package/packages/client/dist/live-types.d.ts +113 -0
  48. package/packages/client/dist/live-types.js +1 -0
  49. package/packages/client/dist/live.d.ts +2 -99
  50. package/packages/client/dist/live.js +16 -77
  51. package/packages/client/dist/types.d.ts +4 -1
  52. package/packages/client/dist/validation.d.ts +1 -0
  53. package/packages/client/dist/validation.js +11 -0
  54. package/packages/shared/dist/protocol.d.ts +3 -0
  55. package/packages/shared/dist/protocol.js +1 -0
@@ -0,0 +1,2 @@
1
+ import type { CommandInput } from "./command.js";
2
+ export declare function handleOrchestrator({ domain, action, positional, flags }: CommandInput): Promise<boolean>;
@@ -0,0 +1,411 @@
1
+ import { CliError, ControllerClient, DEFAULT_COMMAND_TIMEOUT_MS, collectExactTurnEvents, compactText, conciseEvents, deterministicRelayRequestId, deterministicTurnId, deriveOrchestration, normalizeOrchestratorStatus, normalizeOrchestratorTag, normalizeOrchestratorTags, ok, parseDurationMs, parsePermissionMode, promptSha256, readConfig, readOrchestratorMetadata, readPromptTextFile, rebuildTurnOutput, requirePositional, requireString, summarizeTurns, withOrchestratorRequest, withOrchestratorTags, } from "./lib/index.js";
2
+ const defaultTurnHistoryLimit = 1000;
3
+ const defaultMessageLimit = 50;
4
+ class OrchestratorError extends CliError {
5
+ options;
6
+ constructor(message, code, options = {}) {
7
+ super(message, code);
8
+ this.options = options;
9
+ Object.assign(this, options);
10
+ }
11
+ }
12
+ function parsePositiveIntFlag(flags, name) {
13
+ const value = flags[name];
14
+ if (value === undefined || value === false)
15
+ return undefined;
16
+ if (value === true)
17
+ throw new CliError(`Missing --${name} value`, "MISSING_ARGUMENT");
18
+ const parsed = Number(value);
19
+ if (!Number.isInteger(parsed) || parsed <= 0) {
20
+ throw new CliError(`Invalid --${name}: ${value}`, "INVALID_ARGUMENT");
21
+ }
22
+ return parsed;
23
+ }
24
+ function optionalString(flags, name) {
25
+ const value = flags[name];
26
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
27
+ }
28
+ function parseTagsFlag(flags) {
29
+ const tags = requireString(flags, "tags")
30
+ .split(",")
31
+ .map((tag) => tag.trim())
32
+ .filter(Boolean);
33
+ return normalizeOrchestratorTags(tags);
34
+ }
35
+ function machineForSession(machines, session) {
36
+ return machines.find((machine) => machine.id === session.machineId);
37
+ }
38
+ function compactTurnRef(turn, includeOutput) {
39
+ if (!turn)
40
+ return undefined;
41
+ const output = turn.output || turn.terminalText || undefined;
42
+ return {
43
+ turnId: turn.turnId,
44
+ ...(turn.checkpointId ? { checkpointId: turn.checkpointId } : {}),
45
+ ...(turn.runtimeTurnId ? { runtimeTurnId: turn.runtimeTurnId } : {}),
46
+ status: turn.status,
47
+ ...(turn.prompt ? { promptPreview: compactText(turn.prompt, 180) } : {}),
48
+ ...(output ? { outputPreview: compactText(output, 360) } : {}),
49
+ ...(includeOutput && output ? { finalOutput: output } : {}),
50
+ ...(turn.startedAt !== undefined ? { startedAt: turn.startedAt } : {}),
51
+ ...(turn.completedAt !== undefined ? { completedAt: turn.completedAt } : {}),
52
+ updatedAt: turn.updatedAt,
53
+ };
54
+ }
55
+ async function threadSummary(client, session, machines, options = {}) {
56
+ const metadata = await client.decodeSessionMetadata(session);
57
+ const orchestrator = readOrchestratorMetadata(metadata);
58
+ const events = options.events ?? [];
59
+ const turns = events.length > 0 ? summarizeTurns(events) : [];
60
+ const latestTurn = turns[0];
61
+ const orchestration = options.historyFailure
62
+ ? {
63
+ state: "blocked",
64
+ rawStatus: session.status,
65
+ needsUser: false,
66
+ blocked: true,
67
+ completed: false,
68
+ reason: `history unavailable: ${options.historyFailure.code}`,
69
+ }
70
+ : deriveOrchestration(session, events);
71
+ const status = normalizeOrchestratorStatus(session, orchestration, latestTurn);
72
+ const machine = machineForSession(machines, session);
73
+ return {
74
+ threadId: session.id,
75
+ sessionId: session.id,
76
+ name: metadata.name ?? session.name,
77
+ tags: orchestrator.tags ?? [],
78
+ status,
79
+ rawStatus: session.status,
80
+ agent: metadata.agent ?? session.agent,
81
+ cwd: metadata.cwd ?? session.cwd,
82
+ updatedAt: session.updatedAt,
83
+ machine: {
84
+ machineId: session.machineId,
85
+ name: machine?.name,
86
+ online: machine?.online === true,
87
+ },
88
+ orchestration,
89
+ ...(options.historyFailure ? {
90
+ historyStatus: "unavailable",
91
+ historyError: options.historyFailure,
92
+ } : {}),
93
+ ...(options.includeLatestTurn ? { latestTurn: compactTurnRef(latestTurn, options.includeOutput === true) } : {}),
94
+ };
95
+ }
96
+ async function readThreadContext(client, threadId) {
97
+ const snapshot = await client.snapshot();
98
+ const session = snapshot.sessions.find((item) => item.id === threadId);
99
+ if (!session) {
100
+ throw new OrchestratorError(`Thread not found: ${threadId}`, "THREAD_NOT_FOUND", { sessionId: threadId, retryable: false });
101
+ }
102
+ return { machines: snapshot.machines, session };
103
+ }
104
+ function requestRecordForExistingTurn(turn, hash, createdAt) {
105
+ if (!turn)
106
+ return undefined;
107
+ return {
108
+ turnId: turn.turnId,
109
+ promptSha256: hash,
110
+ createdAt: turn.startedAt ?? turn.updatedAt ?? createdAt,
111
+ };
112
+ }
113
+ function errorCode(error) {
114
+ const candidate = error;
115
+ return typeof candidate?.code === "string" ? candidate.code : undefined;
116
+ }
117
+ function retryableError(error) {
118
+ const candidate = error;
119
+ return typeof candidate?.retryable === "boolean" ? candidate.retryable : true;
120
+ }
121
+ function threadHistoryFailure(error) {
122
+ return {
123
+ code: errorCode(error) ?? "HISTORY_UNAVAILABLE",
124
+ message: compactText(error instanceof Error ? error.message : String(error), 240),
125
+ retryable: retryableError(error),
126
+ };
127
+ }
128
+ async function readExactTurn(client, threadId, turnId) {
129
+ const events = await collectExactTurnEvents(client, threadId, turnId, defaultTurnHistoryLimit);
130
+ return summarizeTurns(events).find((turn) => turn.turnId === turnId);
131
+ }
132
+ async function eventsForThreadSummary(client, session) {
133
+ if (session.status !== "running" && session.status !== "new")
134
+ return [];
135
+ return await client.history(session.id, defaultTurnHistoryLimit);
136
+ }
137
+ async function safeEventsForThreadSummary(client, session) {
138
+ try {
139
+ return { events: await eventsForThreadSummary(client, session) };
140
+ }
141
+ catch (error) {
142
+ return { events: [], historyFailure: threadHistoryFailure(error) };
143
+ }
144
+ }
145
+ async function persistRequestRecordBestEffort(client, threadId, requestId, record) {
146
+ for (let attempt = 0; attempt < 3; attempt += 1) {
147
+ const { session } = await readThreadContext(client, threadId);
148
+ const metadata = await client.decodeSessionMetadata(session);
149
+ const existing = readOrchestratorMetadata(metadata).requests?.[requestId];
150
+ if (existing) {
151
+ if (existing.turnId === record.turnId && existing.promptSha256 === record.promptSha256)
152
+ return;
153
+ return;
154
+ }
155
+ try {
156
+ await client.updateSessionMetadata(session.id, withOrchestratorRequest(metadata, requestId, record), { expectedUpdatedAt: session.updatedAt });
157
+ return;
158
+ }
159
+ catch (error) {
160
+ if (errorCode(error) !== "SESSION_METADATA_CONFLICT")
161
+ return;
162
+ }
163
+ }
164
+ }
165
+ function terminalStatusForTurn(turn) {
166
+ if (turn.status === "completed")
167
+ return "completed";
168
+ if (turn.status === "failed")
169
+ return "failed";
170
+ if (turn.status === "cancelled")
171
+ return "cancelled";
172
+ return "inProgress";
173
+ }
174
+ function requestConflict(threadId, requestId, turnId) {
175
+ return new OrchestratorError(`Request id already exists with different prompt content: ${requestId}`, "REQUEST_ID_CONFLICT", { retryable: false, sessionId: threadId, turnId });
176
+ }
177
+ function threadBusy(threadId, active, latestCompleted) {
178
+ return new OrchestratorError(`Thread already has a running turn: ${active.turnId}`, "THREAD_BUSY", {
179
+ retryable: true,
180
+ sessionId: threadId,
181
+ turnId: active.turnId,
182
+ activeTurnId: active.turnId,
183
+ latestCompletedTurnId: latestCompleted?.turnId,
184
+ rawStatus: "running",
185
+ });
186
+ }
187
+ async function waitForTurn(client, threadId, turnId, timeoutMs) {
188
+ const startedAt = Date.now();
189
+ while (true) {
190
+ const turn = await readExactTurn(client, threadId, turnId);
191
+ if (turn && turn.status !== "running")
192
+ return turn;
193
+ if (Date.now() - startedAt >= timeoutMs) {
194
+ throw new OrchestratorError(`Turn ${turnId} is still running or has not reached relay history yet.`, "TURN_WAIT_TIMEOUT", { retryable: true, sessionId: threadId, turnId, rawStatus: turn?.status ?? "missing" });
195
+ }
196
+ await new Promise((resolve) => setTimeout(resolve, 1000));
197
+ }
198
+ }
199
+ function eventsForMessages(events, turns, mode, limit) {
200
+ if (!mode)
201
+ return undefined;
202
+ if (mode !== "latest" && mode !== "all")
203
+ throw new CliError(`Invalid --messages: ${mode}`, "INVALID_ARGUMENT");
204
+ const selected = mode === "latest" && turns[0]
205
+ ? events.filter((event) => event.turnId === turns[0]?.turnId)
206
+ : events;
207
+ return conciseEvents(selected).slice(-limit);
208
+ }
209
+ export async function handleOrchestrator({ domain, action, positional, flags }) {
210
+ if (domain !== "orchestrator")
211
+ return false;
212
+ const config = await readConfig(flags);
213
+ const client = new ControllerClient(config);
214
+ if (action === "list-threads") {
215
+ const snapshot = await client.snapshot();
216
+ const query = optionalString(flags, "query")?.toLowerCase();
217
+ const tag = optionalString(flags, "tag");
218
+ const normalizedTag = tag ? normalizeOrchestratorTag(tag) : undefined;
219
+ const machineId = optionalString(flags, "machine");
220
+ const limit = parsePositiveIntFlag(flags, "limit");
221
+ const candidates = [];
222
+ for (const session of snapshot.sessions) {
223
+ if (machineId && session.machineId !== machineId)
224
+ continue;
225
+ const metadata = await client.decodeSessionMetadata(session);
226
+ const orchestrator = readOrchestratorMetadata(metadata);
227
+ const tags = orchestrator.tags ?? [];
228
+ if (normalizedTag && !tags.includes(normalizedTag))
229
+ continue;
230
+ const searchable = [
231
+ session.id,
232
+ session.machineId,
233
+ metadata.name ?? session.name,
234
+ metadata.cwd ?? session.cwd,
235
+ metadata.agent ?? session.agent,
236
+ ...tags,
237
+ ].join("\n").toLowerCase();
238
+ if (query && !searchable.includes(query))
239
+ continue;
240
+ candidates.push(session);
241
+ }
242
+ const selected = limit === undefined ? candidates : candidates.slice(0, limit);
243
+ const threads = [];
244
+ for (const session of selected) {
245
+ const { events, historyFailure } = await safeEventsForThreadSummary(client, session);
246
+ threads.push(await threadSummary(client, session, snapshot.machines, { events, historyFailure }));
247
+ }
248
+ ok("orchestrator.listThreads", {
249
+ threads,
250
+ page: {
251
+ totalCount: candidates.length,
252
+ returned: threads.length,
253
+ hasMore: threads.length < candidates.length,
254
+ ...(limit !== undefined ? { limit } : {}),
255
+ },
256
+ });
257
+ return true;
258
+ }
259
+ if (action === "read-thread") {
260
+ const threadId = requirePositional(positional[0], "threadId");
261
+ const { session, machines } = await readThreadContext(client, threadId);
262
+ const limit = parsePositiveIntFlag(flags, "limit") ?? defaultMessageLimit;
263
+ const events = await client.history(threadId, defaultTurnHistoryLimit);
264
+ const turns = summarizeTurns(events);
265
+ const includeOutput = flags["include-output"] === true;
266
+ ok("orchestrator.readThread", {
267
+ thread: await threadSummary(client, session, machines, { events, includeLatestTurn: true, includeOutput }),
268
+ ...(eventsForMessages(events, turns, optionalString(flags, "messages"), limit) ? {
269
+ messages: eventsForMessages(events, turns, optionalString(flags, "messages"), limit),
270
+ } : {}),
271
+ }, { sessionId: threadId, turnId: turns[0]?.turnId });
272
+ return true;
273
+ }
274
+ if (action === "send-message") {
275
+ const threadId = requirePositional(positional[0], "threadId");
276
+ const text = typeof flags.text === "string"
277
+ ? flags.text
278
+ : typeof flags["text-file"] === "string"
279
+ ? await readPromptTextFile(flags["text-file"])
280
+ : "";
281
+ if (!text.trim())
282
+ throw new CliError("Missing --text or --text-file", "MISSING_ARGUMENT");
283
+ const requestId = requireString(flags, "request-id");
284
+ const turnId = deterministicTurnId(threadId, requestId);
285
+ const relayRequestId = deterministicRelayRequestId(threadId, requestId);
286
+ const hash = promptSha256(text);
287
+ const self = await client.tokenSelf();
288
+ if (!self.full) {
289
+ throw new OrchestratorError("orchestrator send-message requires a full controller token.", "SCOPED_TOKEN_FORBIDDEN", { retryable: false, sessionId: threadId, turnId });
290
+ }
291
+ await readThreadContext(client, threadId);
292
+ const events = await client.history(threadId, defaultTurnHistoryLimit);
293
+ const turns = summarizeTurns(events);
294
+ const existingTurn = await readExactTurn(client, threadId, turnId);
295
+ if (existingTurn?.prompt && promptSha256(existingTurn.prompt) !== hash)
296
+ throw requestConflict(threadId, requestId, turnId);
297
+ const active = turns.find((turn) => turn.status === "running");
298
+ const latestCompleted = turns.find((turn) => turn.status === "completed");
299
+ if (active && active.turnId !== turnId)
300
+ throw threadBusy(threadId, active, latestCompleted);
301
+ if (existingTurn && existingTurn.status !== "running") {
302
+ await persistRequestRecordBestEffort(client, threadId, requestId, requestRecordForExistingTurn(existingTurn, hash, Date.now()) ?? {
303
+ turnId,
304
+ promptSha256: hash,
305
+ createdAt: Date.now(),
306
+ });
307
+ ok("orchestrator.sendMessage", {
308
+ threadId,
309
+ sessionId: threadId,
310
+ turnId,
311
+ requestId,
312
+ status: terminalStatusForTurn(existingTurn),
313
+ rawStatus: existingTurn.status,
314
+ finalOutput: existingTurn.output || existingTurn.terminalText || undefined,
315
+ }, { requestId, sessionId: threadId, turnId });
316
+ return true;
317
+ }
318
+ if (existingTurn?.status === "running") {
319
+ ok("orchestrator.sendMessage", {
320
+ threadId,
321
+ sessionId: threadId,
322
+ turnId,
323
+ requestId,
324
+ status: "inProgress",
325
+ rawStatus: existingTurn?.status ?? "running",
326
+ }, { requestId, sessionId: threadId, turnId });
327
+ return true;
328
+ }
329
+ const record = { turnId, promptSha256: hash, createdAt: Date.now() };
330
+ const permissionMode = parsePermissionMode(flags["permission-mode"] ?? flags.permission);
331
+ const runtimeTimeoutMs = parseDurationMs(flags["runtime-timeout"], DEFAULT_COMMAND_TIMEOUT_MS);
332
+ try {
333
+ await client.run(threadId, text, {
334
+ wait: false,
335
+ permissionMode,
336
+ requestId: relayRequestId,
337
+ turnId,
338
+ promptHash: hash,
339
+ ackTimeoutMs: 15_000,
340
+ runtimeTimeoutMs,
341
+ });
342
+ }
343
+ catch (error) {
344
+ const enriched = error;
345
+ if (enriched.code === "REQUEST_ID_CONFLICT")
346
+ throw requestConflict(threadId, requestId, turnId);
347
+ if (enriched.code === "SESSION_BUSY") {
348
+ throw new OrchestratorError(enriched.message, "THREAD_BUSY", {
349
+ retryable: true,
350
+ sessionId: threadId,
351
+ turnId,
352
+ activeTurnId: enriched.activeTurnId,
353
+ latestCompletedTurnId: enriched.latestCompletedTurnId,
354
+ rawStatus: "running",
355
+ });
356
+ }
357
+ throw error;
358
+ }
359
+ await persistRequestRecordBestEffort(client, threadId, requestId, record);
360
+ ok("orchestrator.sendMessage", {
361
+ threadId,
362
+ sessionId: threadId,
363
+ turnId,
364
+ requestId,
365
+ status: "inProgress",
366
+ rawStatus: "running",
367
+ }, { requestId, sessionId: threadId, turnId });
368
+ return true;
369
+ }
370
+ if (action === "wait-thread") {
371
+ const threadId = requirePositional(positional[0], "threadId");
372
+ const turnId = requireString(flags, "turn");
373
+ await readThreadContext(client, threadId);
374
+ const turn = await waitForTurn(client, threadId, turnId, parseDurationMs(flags.timeout, DEFAULT_COMMAND_TIMEOUT_MS));
375
+ const output = await rebuildTurnOutput(client, threadId, turnId);
376
+ ok("orchestrator.waitThread", {
377
+ threadId,
378
+ sessionId: threadId,
379
+ turnId,
380
+ status: terminalStatusForTurn(turn),
381
+ rawStatus: turn.status,
382
+ finalOutput: output.output || undefined,
383
+ artifactPaths: {
384
+ output: output.outputPath,
385
+ latest: output.latestPath,
386
+ metadata: output.metadataPath,
387
+ },
388
+ }, { sessionId: threadId, turnId });
389
+ return true;
390
+ }
391
+ if (action === "set-thread-tags") {
392
+ const threadId = requirePositional(positional[0], "threadId");
393
+ const tags = parseTagsFlag(flags);
394
+ const { session, machines } = await readThreadContext(client, threadId);
395
+ const metadata = await client.decodeSessionMetadata(session);
396
+ let updated;
397
+ try {
398
+ updated = await client.updateSessionMetadata(threadId, withOrchestratorTags(metadata, tags), { expectedUpdatedAt: session.updatedAt });
399
+ }
400
+ catch (error) {
401
+ if (errorCode(error) !== "SESSION_METADATA_CONFLICT")
402
+ throw error;
403
+ throw new OrchestratorError("Session metadata changed while setting thread tags; retry with the latest session.", "SESSION_METADATA_CONFLICT", { retryable: true, sessionId: threadId });
404
+ }
405
+ ok("orchestrator.setThreadTags", {
406
+ thread: await threadSummary(client, updated, machines),
407
+ }, { sessionId: threadId });
408
+ return true;
409
+ }
410
+ return false;
411
+ }
@@ -1,4 +1,4 @@
1
- import { CliError, ok, relayHealth, requireRelayUrl, wantsJson } from "./lib/index.js";
1
+ import { CliError, ok, redactedRelayUrl, relayHealth, requireRelayUrl, wantsJson } from "./lib/index.js";
2
2
  export async function handleRelay({ domain, action, flags }) {
3
3
  if (domain === "relay" && action === "serve") {
4
4
  const host = typeof flags.host === "string" ? flags.host : "0.0.0.0";
@@ -40,10 +40,10 @@ export async function handleRelay({ domain, action, flags }) {
40
40
  };
41
41
  }
42
42
  if (wantsJson(flags)) {
43
- ok("relay.status", { relayUrl, health });
43
+ ok("relay.status", { relayUrl: redactedRelayUrl(relayUrl), health });
44
44
  }
45
45
  else {
46
- console.log(`Relay ${relayUrl}: ${health.ok ? "ok" : `HTTP ${health.status}`}`);
46
+ console.log(`Relay ${redactedRelayUrl(relayUrl)}: ${health.ok ? "ok" : `HTTP ${health.status}`}`);
47
47
  }
48
48
  return true;
49
49
  }
@@ -64,11 +64,11 @@ export async function handleRelay({ domain, action, flags }) {
64
64
  }
65
65
  const allOk = checks.every((check) => check.ok);
66
66
  if (wantsJson(flags)) {
67
- ok("relay.doctor", { ok: allOk, relayUrl, checks });
67
+ ok("relay.doctor", { ok: allOk, relayUrl: redactedRelayUrl(relayUrl), checks });
68
68
  }
69
69
  else {
70
70
  console.log(`Relay doctor: ${allOk ? "ok" : "issues found"}`);
71
- console.log(`relay: ${relayUrl}`);
71
+ console.log(`relay: ${redactedRelayUrl(relayUrl)}`);
72
72
  for (const check of checks) {
73
73
  console.log(`${check.ok ? "ok" : "fail"} ${check.name}: ${check.message}`);
74
74
  }
@@ -1,5 +1,5 @@
1
1
  import os from "node:os";
2
- import { ControllerClient, configPath, ok, parseControllerJoinUrl, parseDurationMs, randomId, readConfig, relayHealth, requirePositional, requireRelayUrl, requireString, showMachine, wantsJson, wantsVerbose, writeConfig } from "./lib/index.js";
2
+ import { ControllerClient, configPath, ok, parseControllerJoinUrl, parseDurationMs, randomId, readConfig, redactedRelayUrl, relayHealth, requirePositional, requireRelayUrl, requireString, showMachine, wantsJson, wantsVerbose, writeConfig } from "./lib/index.js";
3
3
  export async function handleRemote({ domain, action, positional, flags }) {
4
4
  if (domain === "remote" && action === "devices") {
5
5
  const config = await readConfig(flags);
@@ -49,7 +49,7 @@ export async function handleRemote({ domain, action, positional, flags }) {
49
49
  client.listDevices(),
50
50
  ]);
51
51
  const data = {
52
- relayUrl: config.relayUrl,
52
+ relayUrl: redactedRelayUrl(config.relayUrl),
53
53
  relay: health,
54
54
  devices: {
55
55
  count: devices.devices.length,
@@ -0,0 +1,2 @@
1
+ import type { CommandInput } from "./command.js";
2
+ export declare function handleSkill({ domain, action, flags }: CommandInput): Promise<boolean>;
@@ -0,0 +1,214 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { ControllerClient, ORCHESTRATOR_CONTRACT_VERSION, ok, readConfig, redactedRelayUrl, requireString, } from "./lib/index.js";
5
+ import { CliError } from "../errors.js";
6
+ const defaultSkillName = "happy-elves-orchestrator";
7
+ function optionalString(flags, name) {
8
+ const value = flags[name];
9
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
10
+ }
11
+ function skillName(flags) {
12
+ const name = optionalString(flags, "name") ?? defaultSkillName;
13
+ if (!/^[a-z0-9._-]+$/u.test(name))
14
+ throw new CliError(`Invalid skill name: ${name}`, "INVALID_ARGUMENT");
15
+ return name;
16
+ }
17
+ function installableSkillText(input) {
18
+ const name = input.skillName ?? defaultSkillName;
19
+ return `---
20
+ name: ${name}
21
+ description: Codex-like orchestration for Happy Elves loaded sessions using a CLI-first API.
22
+ ---
23
+
24
+ # Happy Elves Orchestrator
25
+
26
+ CLI version: ${input.cliVersion}
27
+ Orchestrator contract version: ${input.contractVersion}
28
+
29
+ ## When To Use
30
+ Use this skill when the user asks you to coordinate Happy Elves sessions, workers, reviewers, remote agents, or existing loaded sessions.
31
+
32
+ ## Main Workflow
33
+ Always prefer the orchestrator API:
34
+
35
+ \`\`\`bash
36
+ happy-elves orchestrator list-threads --json
37
+ happy-elves orchestrator read-thread <threadId> --json
38
+ happy-elves orchestrator send-message <threadId> --text "<prompt>" --request-id <stable-id> --json
39
+ happy-elves orchestrator wait-thread <threadId> --turn <turnId> --json
40
+ \`\`\`
41
+
42
+ Important rules:
43
+ - \`threadId\` is the same value as Happy Elves \`sessionId\`.
44
+ - Use stable request ids such as \`orch_round_3_worker_fix\`.
45
+ - Reusing the same request id with the same prompt returns the same turn.
46
+ - Reusing the same request id with different prompt text returns \`REQUEST_ID_CONFLICT\`.
47
+ - If \`send-message\` returns \`THREAD_BUSY\`, wait/read the active turn instead of sending another prompt.
48
+ - For waiting, use the exact \`turnId\` returned by \`send-message\`; do not follow "latest" in multi-actor workflows.
49
+ - After installing or updating this skill, open a fresh Codex thread or reload skills before relying on the new instructions.
50
+
51
+ ## Discovery
52
+ Use tags when the user has designated roles:
53
+
54
+ \`\`\`bash
55
+ happy-elves orchestrator list-threads --tag worker --json
56
+ happy-elves orchestrator set-thread-tags <threadId> --tags worker,architecture-docs --json
57
+ \`\`\`
58
+
59
+ \`list-threads\` returns loaded sessions, including offline or closed sessions already known to the controller. \`--all\` is an explicit alias for this default loaded-session scope.
60
+
61
+ If a running thread cannot load recent history, \`list-threads\` returns that thread with \`historyStatus: "unavailable"\` and \`historyError\`; do not treat it as healthy \`inProgress\`. Use \`read-thread <threadId>\` for a fail-fast exact-thread check.
62
+
63
+ If tags are absent, use \`--query\` to find likely sessions, then ask only if multiple plausible targets remain.
64
+
65
+ ## Reading
66
+ \`read-thread\` is intentionally compact by default. Ask for larger payloads only when needed:
67
+
68
+ \`\`\`bash
69
+ happy-elves orchestrator read-thread <threadId> --messages latest --json
70
+ happy-elves orchestrator read-thread <threadId> --messages all --limit 100 --json
71
+ happy-elves orchestrator read-thread <threadId> --include-output --json
72
+ \`\`\`
73
+
74
+ ## Debug Fallback
75
+ Use lower-level commands only for debugging, history import, repair, rewind, or fork:
76
+
77
+ \`\`\`bash
78
+ happy-elves machine list --json
79
+ happy-elves session list --machine <machineId> --json
80
+ happy-elves session status <sessionId> --verbose --json
81
+ happy-elves turn result <sessionId> <turnId> --json
82
+ \`\`\`
83
+
84
+ ## Safety
85
+ Do not send prompts to real worker/reviewer sessions unless the user has clearly identified the target. For dogfood or probes, create or use a temporary session instead.
86
+ `;
87
+ }
88
+ async function cliVersion() {
89
+ for (const candidate of [
90
+ path.resolve(import.meta.dirname, "..", "..", "..", "..", "package.json"),
91
+ path.resolve(import.meta.dirname, "..", "..", "..", "package.json"),
92
+ path.resolve(import.meta.dirname, "..", "..", "package.json"),
93
+ ]) {
94
+ try {
95
+ const parsed = JSON.parse(await fs.readFile(candidate, "utf8"));
96
+ if (typeof parsed.version === "string" && parsed.version.trim())
97
+ return parsed.version.trim();
98
+ }
99
+ catch {
100
+ // Try the next source or packaged layout.
101
+ }
102
+ }
103
+ return "0.1.0";
104
+ }
105
+ async function skillDoctor(flags) {
106
+ const checks = [];
107
+ const warnings = [];
108
+ checks.push({
109
+ name: "cli-executable",
110
+ status: "ok",
111
+ detail: process.argv[1] ? pathToFileURL(process.argv[1]).toString() : process.execPath,
112
+ });
113
+ checks.push({
114
+ name: "orchestrator-contract",
115
+ status: "ok",
116
+ detail: String(ORCHESTRATOR_CONTRACT_VERSION),
117
+ });
118
+ const installRoot = optionalString(flags, "to");
119
+ if (installRoot) {
120
+ const installed = path.join(installRoot, skillName(flags), "SKILL.md");
121
+ try {
122
+ const source = await fs.readFile(installed, "utf8");
123
+ const okVersion = source.includes(`Orchestrator contract version: ${ORCHESTRATOR_CONTRACT_VERSION}`);
124
+ checks.push({
125
+ name: "installed-skill",
126
+ status: okVersion ? "ok" : "warning",
127
+ detail: installed,
128
+ });
129
+ if (!okVersion)
130
+ warnings.push("Installed skill contract version differs from this CLI.");
131
+ }
132
+ catch (error) {
133
+ checks.push({
134
+ name: "installed-skill",
135
+ status: "warning",
136
+ detail: error instanceof Error ? error.message : String(error),
137
+ });
138
+ warnings.push("Skill is not installed at the provided --to directory.");
139
+ }
140
+ }
141
+ else {
142
+ checks.push({ name: "installed-skill", status: "warning", detail: "Pass --to <skills-dir> to check installation." });
143
+ }
144
+ try {
145
+ const config = await readConfig(flags);
146
+ const client = new ControllerClient(config);
147
+ const machines = await client.listMachines();
148
+ const online = machines.filter((machine) => machine.online).length;
149
+ checks.push({ name: "relay", status: "ok", detail: redactedRelayUrl(config.relayUrl) });
150
+ checks.push({
151
+ name: "online-machine",
152
+ status: online > 0 ? "ok" : "warning",
153
+ detail: `${online}/${machines.length} online`,
154
+ });
155
+ if (online === 0)
156
+ warnings.push("No online Happy Elves machines are currently visible.");
157
+ }
158
+ catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error);
160
+ checks.push({ name: "relay", status: "warning", detail: message });
161
+ warnings.push("Controller config or relay is not currently reachable.");
162
+ }
163
+ return { checks, warnings };
164
+ }
165
+ export async function handleSkill({ domain, action, flags }) {
166
+ if (domain !== "skill")
167
+ return false;
168
+ if (action === "print") {
169
+ console.log(installableSkillText({
170
+ cliVersion: await cliVersion(),
171
+ contractVersion: ORCHESTRATOR_CONTRACT_VERSION,
172
+ skillName: skillName(flags),
173
+ }));
174
+ return true;
175
+ }
176
+ if (action === "install") {
177
+ const root = requireString(flags, "to");
178
+ const name = skillName(flags);
179
+ const dir = path.join(root, name);
180
+ const file = path.join(dir, "SKILL.md");
181
+ await fs.mkdir(dir, { recursive: true });
182
+ if (flags.force !== true) {
183
+ try {
184
+ await fs.access(file);
185
+ throw new CliError(`Skill already exists at ${file}. Re-run with --force to overwrite.`, "SKILL_EXISTS");
186
+ }
187
+ catch (error) {
188
+ if (error.code !== "ENOENT")
189
+ throw error;
190
+ }
191
+ }
192
+ await fs.writeFile(file, installableSkillText({
193
+ cliVersion: await cliVersion(),
194
+ contractVersion: ORCHESTRATOR_CONTRACT_VERSION,
195
+ skillName: name,
196
+ }));
197
+ ok("skill.install", {
198
+ skill: name,
199
+ path: file,
200
+ contractVersion: ORCHESTRATOR_CONTRACT_VERSION,
201
+ });
202
+ return true;
203
+ }
204
+ if (action === "doctor") {
205
+ const result = await skillDoctor(flags);
206
+ ok("skill.doctor", {
207
+ contractVersion: ORCHESTRATOR_CONTRACT_VERSION,
208
+ checks: result.checks,
209
+ warnings: result.warnings,
210
+ });
211
+ return true;
212
+ }
213
+ return false;
214
+ }