@otto-code/cli 0.7.4 → 0.7.6

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 (70) hide show
  1. package/dist/cli.js +25 -2
  2. package/dist/commands/agent/delete.js +1 -1
  3. package/dist/commands/agent/detach.d.ts +9 -0
  4. package/dist/commands/agent/detach.js +38 -0
  5. package/dist/commands/agent/index.js +9 -1
  6. package/dist/commands/agent/open.d.ts +11 -0
  7. package/dist/commands/agent/open.js +61 -0
  8. package/dist/commands/agent/run.d.ts +35 -0
  9. package/dist/commands/agent/run.js +142 -49
  10. package/dist/commands/agent/update.d.ts +33 -0
  11. package/dist/commands/agent/update.js +72 -25
  12. package/dist/commands/clone.d.ts +17 -0
  13. package/dist/commands/clone.js +65 -0
  14. package/dist/commands/daemon/local-daemon.d.ts +15 -1
  15. package/dist/commands/daemon/local-daemon.js +12 -5
  16. package/dist/commands/daemon/pair.js +6 -2
  17. package/dist/commands/heartbeat/index.d.ts +3 -0
  18. package/dist/commands/heartbeat/index.js +139 -0
  19. package/dist/commands/hub/cloud-device-authorization.d.ts +45 -0
  20. package/dist/commands/hub/cloud-device-authorization.js +92 -0
  21. package/dist/commands/hub/device-authorization.d.ts +37 -0
  22. package/dist/commands/hub/device-authorization.js +87 -0
  23. package/dist/commands/hub/index.d.ts +30 -0
  24. package/dist/commands/hub/index.js +85 -0
  25. package/dist/commands/hub-disabled.d.ts +22 -0
  26. package/dist/commands/hub-disabled.js +34 -0
  27. package/dist/commands/onboard.js +6 -2
  28. package/dist/commands/open.d.ts +2 -0
  29. package/dist/commands/open.js +22 -17
  30. package/dist/commands/schedule/create.d.ts +1 -0
  31. package/dist/commands/schedule/create.js +1 -0
  32. package/dist/commands/schedule/index.js +6 -6
  33. package/dist/commands/schedule/inspect.js +3 -0
  34. package/dist/commands/schedule/logs.js +2 -1
  35. package/dist/commands/schedule/ls.js +3 -1
  36. package/dist/commands/schedule/pause.js +2 -1
  37. package/dist/commands/schedule/resume.js +2 -1
  38. package/dist/commands/schedule/run-once.js +2 -1
  39. package/dist/commands/schedule/shared.d.ts +3 -0
  40. package/dist/commands/schedule/shared.js +35 -23
  41. package/dist/commands/schedule/update.js +2 -1
  42. package/dist/commands/script/index.d.ts +3 -0
  43. package/dist/commands/script/index.js +19 -0
  44. package/dist/commands/script/ls.d.ts +6 -0
  45. package/dist/commands/script/ls.js +20 -0
  46. package/dist/commands/script/schema.d.ts +5 -0
  47. package/dist/commands/script/schema.js +13 -0
  48. package/dist/commands/script/shared.d.ts +11 -0
  49. package/dist/commands/script/shared.js +59 -0
  50. package/dist/commands/script/start.d.ts +6 -0
  51. package/dist/commands/script/start.js +23 -0
  52. package/dist/commands/script/stop.d.ts +6 -0
  53. package/dist/commands/script/stop.js +23 -0
  54. package/dist/commands/workspace/archive.d.ts +12 -0
  55. package/dist/commands/workspace/archive.js +41 -0
  56. package/dist/commands/workspace/create.d.ts +49 -0
  57. package/dist/commands/workspace/create.js +114 -0
  58. package/dist/commands/workspace/index.d.ts +3 -0
  59. package/dist/commands/workspace/index.js +30 -0
  60. package/dist/commands/workspace/ls.d.ts +7 -0
  61. package/dist/commands/workspace/ls.js +28 -0
  62. package/dist/commands/workspace/shared.d.ts +12 -0
  63. package/dist/commands/workspace/shared.js +20 -0
  64. package/dist/output/pairing.d.ts +8 -0
  65. package/dist/output/pairing.js +24 -0
  66. package/dist/utils/client.d.ts +9 -0
  67. package/dist/utils/client.js +9 -0
  68. package/dist/utils/duration.d.ts +1 -1
  69. package/dist/utils/duration.js +8 -7
  70. package/package.json +10 -7
@@ -6,8 +6,35 @@ export const updateSchema = {
6
6
  { header: "AGENT ID", field: "agentId" },
7
7
  { header: "NAME", field: "name" },
8
8
  { header: "LABELS", field: "labels" },
9
+ { header: "THINKING", field: "thinkingOptionId" },
10
+ { header: "NOTICE", field: "notice" },
9
11
  ],
10
12
  };
13
+ export function toAgentUpdateResult(agent, appliedChanges) {
14
+ return {
15
+ agentId: agent.id,
16
+ name: agent.title,
17
+ labels: formatLabels(agent.labels),
18
+ thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
19
+ noticeType: appliedChanges.notice?.type ?? null,
20
+ notice: appliedChanges.notice?.message ?? null,
21
+ };
22
+ }
23
+ export async function applyAgentChanges(client, agentId, changes) {
24
+ if (changes.type === "thinking") {
25
+ // COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
26
+ if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
27
+ throw {
28
+ code: "DAEMON_UPDATE_REQUIRED",
29
+ message: "Update the host to use agent thinking updates.",
30
+ };
31
+ }
32
+ const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
33
+ return { notice };
34
+ }
35
+ await client.updateAgent(agentId, changes.updates);
36
+ return { notice: null };
37
+ }
11
38
  function parseLabelOptions(labels) {
12
39
  const parsed = {};
13
40
  if (!labels) {
@@ -50,35 +77,62 @@ function formatLabels(labels) {
50
77
  }
51
78
  return entries.map(([key, value]) => `${key}=${value}`).join(",");
52
79
  }
53
- export async function runUpdateCommand(agentIdArg, options, _command) {
54
- const host = getDaemonHost({ host: options.host });
55
- // Validate arguments
56
- if (!agentIdArg || agentIdArg.trim().length === 0) {
57
- const error = {
58
- code: "MISSING_AGENT_ID",
59
- message: "Agent ID is required",
60
- details: "Usage: otto agent update <id> [--name <name>] [--label <key=value>]",
61
- };
62
- throw error;
63
- }
80
+ function parseAgentChanges(options) {
64
81
  const name = options.name?.trim();
65
82
  if (options.name !== undefined && !name) {
66
- const error = {
83
+ throw {
67
84
  code: "INVALID_NAME",
68
85
  message: "Name cannot be empty",
69
86
  details: "Use --name <name> with a non-empty value",
70
87
  };
71
- throw error;
72
88
  }
73
89
  const labels = parseLabelOptions(options.label);
74
- if (!name && Object.keys(labels).length === 0) {
75
- const error = {
90
+ const thinkingOptionId = options.thinking?.trim();
91
+ if (options.thinking !== undefined && !thinkingOptionId) {
92
+ throw {
93
+ code: "INVALID_THINKING_OPTION",
94
+ message: "--thinking cannot be empty",
95
+ details: 'Provide a thinking option ID. Use "otto provider models <provider> --thinking" to list valid IDs.',
96
+ };
97
+ }
98
+ const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
99
+ if (hasMetadataUpdates && thinkingOptionId) {
100
+ throw {
101
+ code: "INVALID_OPTIONS",
102
+ message: "--thinking cannot be combined with --name or --label",
103
+ details: "Run separate agent update commands for runtime settings and metadata.",
104
+ };
105
+ }
106
+ if (!hasMetadataUpdates && !thinkingOptionId) {
107
+ throw {
76
108
  code: "NO_CHANGES_PROVIDED",
77
109
  message: "Nothing to update",
78
- details: "Provide at least one of: --name <name>, --label <key=value>",
110
+ details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
111
+ };
112
+ }
113
+ if (thinkingOptionId) {
114
+ return { type: "thinking", thinkingOptionId };
115
+ }
116
+ return {
117
+ type: "metadata",
118
+ updates: {
119
+ ...(name ? { name } : {}),
120
+ ...(Object.keys(labels).length > 0 ? { labels } : {}),
121
+ },
122
+ };
123
+ }
124
+ export async function runUpdateCommand(agentIdArg, options, _command) {
125
+ const host = getDaemonHost({ host: options.host });
126
+ // Validate arguments
127
+ if (!agentIdArg || agentIdArg.trim().length === 0) {
128
+ const error = {
129
+ code: "MISSING_AGENT_ID",
130
+ message: "Agent ID is required",
131
+ details: "Usage: otto agent update <id> [--name <name>] [--label <key=value>]",
79
132
  };
80
133
  throw error;
81
134
  }
135
+ const changes = parseAgentChanges(options);
82
136
  let client;
83
137
  try {
84
138
  client = await connectToDaemon({ host: options.host });
@@ -103,10 +157,7 @@ export async function runUpdateCommand(agentIdArg, options, _command) {
103
157
  throw error;
104
158
  }
105
159
  const agentId = fetchResult.agent.id;
106
- await client.updateAgent(agentId, {
107
- ...(name ? { name } : {}),
108
- ...(Object.keys(labels).length > 0 ? { labels } : {}),
109
- });
160
+ const appliedChanges = await applyAgentChanges(client, agentId, changes);
110
161
  const updatedResult = await client.fetchAgent({ agentId });
111
162
  if (!updatedResult) {
112
163
  throw new Error(`Agent not found after update: ${agentId}`);
@@ -114,11 +165,7 @@ export async function runUpdateCommand(agentIdArg, options, _command) {
114
165
  await client.close();
115
166
  return {
116
167
  type: "single",
117
- data: {
118
- agentId,
119
- name: updatedResult.agent.title,
120
- labels: formatLabels(updatedResult.agent.labels),
121
- },
168
+ data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
122
169
  schema: updateSchema,
123
170
  };
124
171
  }
@@ -0,0 +1,17 @@
1
+ import type { Command } from "commander";
2
+ import type { OutputSchema, SingleResult } from "../output/index.js";
3
+ import type { CommandOptions } from "../output/with-output.js";
4
+ type CloneProtocol = "https" | "ssh";
5
+ interface CloneCommandOptions extends CommandOptions {
6
+ protocol?: CloneProtocol;
7
+ }
8
+ export interface CloneResult {
9
+ repo: string;
10
+ checkoutPath: string;
11
+ projectId: string;
12
+ projectName: string;
13
+ }
14
+ export declare const cloneSchema: OutputSchema<CloneResult>;
15
+ export declare function runCloneCommand(repo: string, options: CloneCommandOptions, _command: Command): Promise<SingleResult<CloneResult>>;
16
+ export {};
17
+ //# sourceMappingURL=clone.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { isCompleteGitRemote } from "@otto-code/protocol/git-remote";
2
+ import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js";
3
+ export const cloneSchema = {
4
+ idField: "projectId",
5
+ columns: [
6
+ { header: "REPO", field: "repo", width: 28 },
7
+ { header: "PROJECT", field: "projectName", width: 28 },
8
+ { header: "PATH", field: "checkoutPath", width: 56 },
9
+ ],
10
+ };
11
+ function cmdError(code, message, details) {
12
+ return details ? { code, message, details } : { code, message };
13
+ }
14
+ export async function runCloneCommand(repo, options, _command) {
15
+ const targetDirectory = typeof options.dir === "string" ? options.dir.trim() : "";
16
+ if (!targetDirectory) {
17
+ throw cmdError("INVALID_ARGUMENT", "--dir is required");
18
+ }
19
+ const repoIsCompleteRemote = isCompleteGitRemote(repo);
20
+ if (!repoIsCompleteRemote && !options.protocol) {
21
+ throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names");
22
+ }
23
+ let client;
24
+ try {
25
+ client = await connectToDaemon({ host: options.host });
26
+ }
27
+ catch (err) {
28
+ throw buildDaemonConnectionCommandError({ host: options.host, error: err });
29
+ }
30
+ if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) {
31
+ await client.close().catch(() => { });
32
+ throw cmdError("UNSUPPORTED_BY_HOST", "This daemon does not support cloning GitHub repos.", "Update the host to a newer Otto version.");
33
+ }
34
+ try {
35
+ const response = await client.cloneGithubProject({
36
+ repo,
37
+ targetDirectory,
38
+ ...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }),
39
+ });
40
+ if (response.error || !response.project || !response.checkoutPath) {
41
+ throw cmdError("CLONE_FAILED", `Failed to clone GitHub repo: ${response.error ?? "no project returned"}`);
42
+ }
43
+ return {
44
+ type: "single",
45
+ data: {
46
+ repo: response.repo,
47
+ checkoutPath: response.checkoutPath,
48
+ projectId: response.project.projectId,
49
+ projectName: response.project.projectDisplayName,
50
+ },
51
+ schema: cloneSchema,
52
+ };
53
+ }
54
+ catch (err) {
55
+ if (err && typeof err === "object" && "code" in err) {
56
+ throw err;
57
+ }
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ throw cmdError("CLONE_FAILED", `Failed to clone GitHub repo: ${message}`);
60
+ }
61
+ finally {
62
+ await client.close().catch(() => { });
63
+ }
64
+ }
65
+ //# sourceMappingURL=clone.js.map
@@ -22,7 +22,21 @@ export interface LocalDaemonPidInfo {
22
22
  }
23
23
  export interface LocalDaemonState {
24
24
  home: string;
25
+ /**
26
+ * Where a daemon for this home *would* listen: the pid file's address when one
27
+ * exists, otherwise the configured default. Safe for display and for deciding
28
+ * where to start, but NOT for deciding what to shut down -- on a home that owns
29
+ * no daemon this falls back to the global default (127.0.0.1:6868), which
30
+ * belongs to whatever daemon happens to be running there.
31
+ */
25
32
  listen: string;
33
+ /**
34
+ * Where the daemon *this home actually owns* is listening, or null when this
35
+ * home owns no daemon. Lifecycle operations that stop a daemon must use this:
36
+ * a home with no pid file has nothing to stop, and must never inherit the
37
+ * default port and reach into another home's daemon.
38
+ */
39
+ ownedListen: string | null;
26
40
  relayEnabled: boolean;
27
41
  relayEndpoint: string;
28
42
  relayUseTls: boolean;
@@ -67,7 +81,7 @@ export interface DaemonLaunchRuntime {
67
81
  export declare const DEFAULT_STOP_TIMEOUT_MS = 15000;
68
82
  export declare const DEFAULT_KILL_TIMEOUT_MS = 3000;
69
83
  export declare function resolveLocalOttoHome(home?: string): string;
70
- export declare function resolveTcpHostFromListen(listen: string): string | null;
84
+ export declare function resolveTcpHostFromListen(listen: string | null | undefined): string | null;
71
85
  export declare function resolveLocalDaemonState(options?: {
72
86
  home?: string;
73
87
  }): LocalDaemonState;
@@ -298,7 +298,7 @@ async function waitForPidExit(pid, timeoutMs) {
298
298
  return poll();
299
299
  }
300
300
  async function waitForDaemonUnreachable(state, timeoutMs) {
301
- const host = resolveTcpHostFromListen(state.listen);
301
+ const host = resolveTcpHostFromListen(state.ownedListen);
302
302
  if (!host) {
303
303
  return true;
304
304
  }
@@ -350,7 +350,7 @@ function createNotRunningStopResult(state, pid, message) {
350
350
  }
351
351
  function createStopTimeoutError(state, pid, timeoutMs) {
352
352
  if (!state.running) {
353
- const host = resolveTcpHostFromListen(state.listen);
353
+ const host = resolveTcpHostFromListen(state.ownedListen);
354
354
  return new Error(`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(timeoutMs / 1000)}s`);
355
355
  }
356
356
  return new Error(`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`);
@@ -384,7 +384,7 @@ export function resolveLocalOttoHome(home) {
384
384
  return resolveOttoHome(envWithHome(home));
385
385
  }
386
386
  export function resolveTcpHostFromListen(listen) {
387
- const normalized = listen.trim();
387
+ const normalized = listen?.trim() ?? "";
388
388
  if (!normalized) {
389
389
  return null;
390
390
  }
@@ -424,9 +424,14 @@ export function resolveLocalDaemonState(options = {}) {
424
424
  const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
425
425
  const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
426
426
  const listen = pidInfo?.listen ?? config.listen;
427
+ // Only a home that owns a pid file owns a daemon. Older pid files predate the
428
+ // `listen` field, so fall back to the configured address -- but only when a pid
429
+ // file proves this home owns something to talk to.
430
+ const ownedListen = pidInfo ? (pidInfo.listen ?? config.listen) : null;
427
431
  return {
428
432
  home,
429
433
  listen,
434
+ ownedListen,
430
435
  relayEnabled: config.relayEnabled ?? true,
431
436
  relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.otto-code.me:443",
432
437
  relayUseTls: config.relayUseTls ?? false,
@@ -510,11 +515,13 @@ export function startLocalDaemonForeground(options, runtime = defaultDaemonLaunc
510
515
  return result.status ?? 1;
511
516
  }
512
517
  async function requestLifecycleShutdown(state, timeoutMs) {
513
- const host = resolveTcpHostFromListen(state.listen);
518
+ const host = resolveTcpHostFromListen(state.ownedListen);
514
519
  if (!host) {
515
520
  return {
516
521
  requested: false,
517
- reason: "daemon listen target is not TCP, falling back to owner PID signal",
522
+ reason: state.pidInfo
523
+ ? "daemon listen target is not TCP, falling back to owner PID signal"
524
+ : "this home owns no daemon, skipping lifecycle shutdown",
518
525
  };
519
526
  }
520
527
  const deadline = Date.now() + timeoutMs;
@@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolveOttoHome } from "@otto-co
4
4
  import { tryConnectToDaemon } from "../../utils/client.js";
5
5
  import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
6
6
  import { addJsonOption } from "../../utils/command-options.js";
7
+ import { formatPairingInstructions } from "../../output/pairing.js";
7
8
  const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
8
9
  export function pairCommand() {
9
10
  return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
@@ -70,7 +71,10 @@ function outputPairingResult(pairing, options) {
70
71
  }, null, 2)}\n`);
71
72
  return;
72
73
  }
73
- const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
74
- process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
74
+ process.stdout.write(formatPairingInstructions({
75
+ url: pairing.url,
76
+ qr: pairing.qr,
77
+ columns: process.stdout.columns,
78
+ }));
75
79
  }
76
80
  //# sourceMappingURL=pair.js.map
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function createHeartbeatCommand(): Command;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { Command } from "commander";
2
+ import { withOutput } from "../../output/index.js";
3
+ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
4
+ import { parseDuration } from "../../utils/duration.js";
5
+ import { connectScheduleClient, toScheduleCommandError, toScheduleRow, } from "../schedule/shared.js";
6
+ import { scheduleSchema } from "../schedule/schema.js";
7
+ const heartbeatDeleteSchema = {
8
+ idField: "id",
9
+ columns: [
10
+ { header: "ID", field: "id" },
11
+ { header: "STATUS", field: "status" },
12
+ ],
13
+ };
14
+ function requireCallerAgentId() {
15
+ const agentId = process.env.OTTO_AGENT_ID?.trim();
16
+ if (!agentId) {
17
+ throw new Error("Heartbeat commands must run inside a Otto agent");
18
+ }
19
+ return agentId;
20
+ }
21
+ async function requireOwnedHeartbeat(client, id, agentId) {
22
+ const payload = await client.scheduleInspect({ id });
23
+ if (payload.error || !payload.schedule) {
24
+ throw new Error(payload.error ?? `Heartbeat not found: ${id}`);
25
+ }
26
+ if (payload.schedule.target.type !== "agent" || payload.schedule.target.agentId !== agentId) {
27
+ throw new Error(`Heartbeat ${id} does not belong to agent ${agentId}`);
28
+ }
29
+ }
30
+ async function runCreateHeartbeat(prompt, options, _command) {
31
+ const agentId = requireCallerAgentId();
32
+ const cron = options.cron?.trim();
33
+ if (!cron) {
34
+ throw new Error("--cron is required");
35
+ }
36
+ const { client } = await connectScheduleClient(options.host);
37
+ try {
38
+ const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
39
+ if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
40
+ throw new Error("--max-runs must be a positive integer");
41
+ }
42
+ const payload = await client.scheduleCreate({
43
+ prompt: prompt.trim(),
44
+ cadence: {
45
+ type: "cron",
46
+ expression: cron,
47
+ ...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
48
+ },
49
+ target: { type: "agent", agentId },
50
+ ...(options.name?.trim() ? { name: options.name.trim() } : {}),
51
+ ...(maxRuns ? { maxRuns } : {}),
52
+ ...(options.expiresIn
53
+ ? { expiresAt: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString() }
54
+ : {}),
55
+ });
56
+ if (payload.error || !payload.schedule) {
57
+ throw new Error(payload.error ?? "Heartbeat creation failed");
58
+ }
59
+ return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
60
+ }
61
+ catch (error) {
62
+ throw toScheduleCommandError("HEARTBEAT_CREATE_FAILED", "create heartbeat", error);
63
+ }
64
+ finally {
65
+ await client.close().catch(() => undefined);
66
+ }
67
+ }
68
+ async function runUpdateHeartbeat(id, options, _command) {
69
+ const agentId = requireCallerAgentId();
70
+ const cron = options.cron?.trim();
71
+ if (!cron) {
72
+ throw new Error("--cron is required");
73
+ }
74
+ const { client } = await connectScheduleClient(options.host);
75
+ try {
76
+ await requireOwnedHeartbeat(client, id, agentId);
77
+ const payload = await client.scheduleUpdate({
78
+ id,
79
+ cadence: {
80
+ type: "cron",
81
+ expression: cron,
82
+ ...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
83
+ },
84
+ });
85
+ if (payload.error || !payload.schedule) {
86
+ throw new Error(payload.error ?? `Heartbeat update failed: ${id}`);
87
+ }
88
+ return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
89
+ }
90
+ catch (error) {
91
+ throw toScheduleCommandError("HEARTBEAT_UPDATE_FAILED", "update heartbeat", error);
92
+ }
93
+ finally {
94
+ await client.close().catch(() => undefined);
95
+ }
96
+ }
97
+ async function runDeleteHeartbeat(id, options, _command) {
98
+ const agentId = requireCallerAgentId();
99
+ const { client } = await connectScheduleClient(options.host);
100
+ try {
101
+ await requireOwnedHeartbeat(client, id, agentId);
102
+ const payload = await client.scheduleDelete({ id });
103
+ if (payload.error) {
104
+ throw new Error(payload.error);
105
+ }
106
+ return {
107
+ type: "single",
108
+ data: { id: payload.scheduleId, status: "deleted" },
109
+ schema: heartbeatDeleteSchema,
110
+ };
111
+ }
112
+ catch (error) {
113
+ throw toScheduleCommandError("HEARTBEAT_DELETE_FAILED", "delete heartbeat", error);
114
+ }
115
+ finally {
116
+ await client.close().catch(() => undefined);
117
+ }
118
+ }
119
+ export function createHeartbeatCommand() {
120
+ const heartbeat = new Command("heartbeat").description("Manage this agent's heartbeats");
121
+ addJsonAndDaemonHostOptions(heartbeat
122
+ .command("create")
123
+ .description("Create a recurring prompt for this agent")
124
+ .argument("<prompt>", "Prompt to send")
125
+ .requiredOption("--cron <expr>", "Five-field cron cadence")
126
+ .option("--timezone <iana>", "IANA time zone")
127
+ .option("--name <name>", "Heartbeat name")
128
+ .option("--max-runs <n>", "Maximum number of runs")
129
+ .option("--expires-in <duration>", "Time to live")).action(withOutput(runCreateHeartbeat));
130
+ addJsonAndDaemonHostOptions(heartbeat
131
+ .command("update")
132
+ .description("Change a heartbeat cron cadence")
133
+ .argument("<id>", "Heartbeat ID")
134
+ .requiredOption("--cron <expr>", "Five-field cron cadence")
135
+ .option("--timezone <iana>", "IANA time zone")).action(withOutput(runUpdateHeartbeat));
136
+ addJsonAndDaemonHostOptions(heartbeat.command("delete").description("Delete a heartbeat").argument("<id>", "Heartbeat ID")).action(withOutput(runDeleteHeartbeat));
137
+ return heartbeat;
138
+ }
139
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ declare const authorizationSchema: z.ZodObject<{
3
+ deviceCode: z.ZodString;
4
+ userCode: z.ZodString;
5
+ verificationUri: z.ZodURL;
6
+ verificationUriComplete: z.ZodURL;
7
+ expiresAt: z.ZodString;
8
+ interval: z.ZodNumber;
9
+ }, z.core.$strip>;
10
+ declare const pollSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
11
+ status: z.ZodLiteral<"pending">;
12
+ interval: z.ZodNumber;
13
+ }, z.core.$strip>, z.ZodObject<{
14
+ status: z.ZodLiteral<"slow_down">;
15
+ interval: z.ZodNumber;
16
+ }, z.core.$strip>, z.ZodObject<{
17
+ status: z.ZodLiteral<"approved">;
18
+ interval: z.ZodNumber;
19
+ enrollmentToken: z.ZodString;
20
+ }, z.core.$strip>, z.ZodObject<{
21
+ status: z.ZodLiteral<"denied">;
22
+ interval: z.ZodNumber;
23
+ }, z.core.$strip>, z.ZodObject<{
24
+ status: z.ZodLiteral<"expired">;
25
+ interval: z.ZodNumber;
26
+ }, z.core.$strip>, z.ZodObject<{
27
+ status: z.ZodLiteral<"enrolled">;
28
+ interval: z.ZodNumber;
29
+ }, z.core.$strip>, z.ZodObject<{
30
+ status: z.ZodLiteral<"retry_later">;
31
+ }, z.core.$strip>], "status">;
32
+ export type DeviceAuthorization = z.infer<typeof authorizationSchema>;
33
+ export type DeviceAuthorizationPoll = z.infer<typeof pollSchema>;
34
+ export interface CloudDeviceAuthorization {
35
+ start(hubUrl: string, displayName: string): Promise<DeviceAuthorization>;
36
+ poll(hubUrl: string, deviceCode: string, timeoutMilliseconds: number): Promise<DeviceAuthorizationPoll>;
37
+ }
38
+ export declare class CloudDeviceAuthorizationClient implements CloudDeviceAuthorization {
39
+ private readonly startTimeoutMilliseconds;
40
+ constructor(startTimeoutMilliseconds?: number);
41
+ start(hubUrl: string, displayName: string): Promise<DeviceAuthorization>;
42
+ poll(hubUrl: string, deviceCode: string, timeoutMilliseconds: number): Promise<DeviceAuthorizationPoll>;
43
+ }
44
+ export {};
45
+ //# sourceMappingURL=cloud-device-authorization.d.ts.map
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ const START_TIMEOUT_MS = 15000;
3
+ const activationUrlSchema = z.url({ protocol: /^https?$/u });
4
+ const authorizationSchema = z.object({
5
+ deviceCode: z.string().min(32),
6
+ userCode: z.string().min(1),
7
+ verificationUri: activationUrlSchema,
8
+ verificationUriComplete: activationUrlSchema,
9
+ expiresAt: z.string().datetime(),
10
+ interval: z.number().int().min(5),
11
+ });
12
+ const pollSchema = z.discriminatedUnion("status", [
13
+ z.object({ status: z.literal("pending"), interval: z.number().int().min(5) }),
14
+ z.object({ status: z.literal("slow_down"), interval: z.number().int().min(5) }),
15
+ z.object({
16
+ status: z.literal("approved"),
17
+ interval: z.number().int().min(5),
18
+ enrollmentToken: z.string().min(32),
19
+ }),
20
+ z.object({ status: z.literal("denied"), interval: z.number().int().min(5) }),
21
+ z.object({ status: z.literal("expired"), interval: z.number().int().min(5) }),
22
+ z.object({ status: z.literal("enrolled"), interval: z.number().int().min(5) }),
23
+ z.object({ status: z.literal("retry_later") }),
24
+ ]);
25
+ export class CloudDeviceAuthorizationClient {
26
+ constructor(startTimeoutMilliseconds = START_TIMEOUT_MS) {
27
+ this.startTimeoutMilliseconds = startTimeoutMilliseconds;
28
+ }
29
+ async start(hubUrl, displayName) {
30
+ const signal = AbortSignal.timeout(this.startTimeoutMilliseconds);
31
+ try {
32
+ const response = await fetch(endpoint(hubUrl, "/api/device-authorizations/"), {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify({ displayName }),
36
+ signal,
37
+ });
38
+ if (!response.ok)
39
+ throw new Error(`Cloud registration failed (${response.status})`);
40
+ return authorizationSchema.parse(await response.json());
41
+ }
42
+ catch (error) {
43
+ if (signal.aborted) {
44
+ throw new Error("Cloud registration start timed out", { cause: error });
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ async poll(hubUrl, deviceCode, timeoutMilliseconds) {
50
+ const signal = AbortSignal.timeout(timeoutMilliseconds);
51
+ let response;
52
+ try {
53
+ response = await fetch(endpoint(hubUrl, "/api/device-authorizations/poll"), {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({ deviceCode }),
57
+ signal,
58
+ });
59
+ }
60
+ catch {
61
+ return { status: "retry_later" };
62
+ }
63
+ if ([408, 425, 429].includes(response.status) || response.status >= 500) {
64
+ return { status: "retry_later" };
65
+ }
66
+ if (!response.ok)
67
+ throw new Error(`Cloud registration poll failed (${response.status})`);
68
+ let body;
69
+ try {
70
+ body = await response.json();
71
+ }
72
+ catch (error) {
73
+ if (signal.aborted || error instanceof TypeError)
74
+ return { status: "retry_later" };
75
+ throw error;
76
+ }
77
+ return pollSchema.parse(body);
78
+ }
79
+ }
80
+ function endpoint(hubUrl, pathname) {
81
+ const url = new URL(hubUrl);
82
+ if (!["http:", "https:"].includes(url.protocol) ||
83
+ url.username ||
84
+ url.password ||
85
+ url.search ||
86
+ url.hash) {
87
+ throw new Error("Hub URL must be an HTTP or HTTPS origin without credentials or a query");
88
+ }
89
+ url.pathname = `${url.pathname.replace(/\/$/u, "")}${pathname}`;
90
+ return url.toString();
91
+ }
92
+ //# sourceMappingURL=cloud-device-authorization.js.map
@@ -0,0 +1,37 @@
1
+ import { type CloudDeviceAuthorization } from "./cloud-device-authorization.js";
2
+ export interface AuthorizationWaiter {
3
+ wait(milliseconds: number): Promise<void>;
4
+ now(): number;
5
+ }
6
+ export interface BrowserOpener {
7
+ open(url: string): Promise<void>;
8
+ }
9
+ type BrowserLaunch = (command: string, args: string[]) => Promise<void>;
10
+ interface SystemBrowserOptions {
11
+ hostPlatform?: NodeJS.Platform;
12
+ launch?: BrowserLaunch;
13
+ }
14
+ export declare class SystemBrowser implements BrowserOpener {
15
+ private readonly hostPlatform;
16
+ private readonly launch;
17
+ constructor(options?: SystemBrowserOptions);
18
+ open(url: string): Promise<void>;
19
+ }
20
+ export interface AuthorizationReporter {
21
+ instructions(verificationUri: string, userCode: string): void;
22
+ }
23
+ interface DeviceAuthorizationWorkflowOptions {
24
+ cloud: CloudDeviceAuthorization;
25
+ waiter: AuthorizationWaiter;
26
+ browser: BrowserOpener;
27
+ reporter: AuthorizationReporter;
28
+ openBrowser?: boolean;
29
+ }
30
+ export declare class DeviceAuthorizationWorkflow {
31
+ private readonly options;
32
+ constructor(options: DeviceAuthorizationWorkflowOptions);
33
+ authorize(hubUrl: string, displayName: string): Promise<string>;
34
+ }
35
+ export declare function createDeviceAuthorizationWorkflow(): DeviceAuthorizationWorkflow;
36
+ export {};
37
+ //# sourceMappingURL=device-authorization.d.ts.map