@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.
- package/dist/cli.js +25 -2
- package/dist/commands/agent/delete.js +1 -1
- package/dist/commands/agent/detach.d.ts +9 -0
- package/dist/commands/agent/detach.js +38 -0
- package/dist/commands/agent/index.js +9 -1
- package/dist/commands/agent/open.d.ts +11 -0
- package/dist/commands/agent/open.js +61 -0
- package/dist/commands/agent/run.d.ts +35 -0
- package/dist/commands/agent/run.js +142 -49
- package/dist/commands/agent/update.d.ts +33 -0
- package/dist/commands/agent/update.js +72 -25
- package/dist/commands/clone.d.ts +17 -0
- package/dist/commands/clone.js +65 -0
- package/dist/commands/daemon/local-daemon.d.ts +15 -1
- package/dist/commands/daemon/local-daemon.js +12 -5
- package/dist/commands/daemon/pair.js +6 -2
- package/dist/commands/heartbeat/index.d.ts +3 -0
- package/dist/commands/heartbeat/index.js +139 -0
- package/dist/commands/hub/cloud-device-authorization.d.ts +45 -0
- package/dist/commands/hub/cloud-device-authorization.js +92 -0
- package/dist/commands/hub/device-authorization.d.ts +37 -0
- package/dist/commands/hub/device-authorization.js +87 -0
- package/dist/commands/hub/index.d.ts +30 -0
- package/dist/commands/hub/index.js +85 -0
- package/dist/commands/hub-disabled.d.ts +22 -0
- package/dist/commands/hub-disabled.js +34 -0
- package/dist/commands/onboard.js +6 -2
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +22 -17
- package/dist/commands/schedule/create.d.ts +1 -0
- package/dist/commands/schedule/create.js +1 -0
- package/dist/commands/schedule/index.js +6 -6
- package/dist/commands/schedule/inspect.js +3 -0
- package/dist/commands/schedule/logs.js +2 -1
- package/dist/commands/schedule/ls.js +3 -1
- package/dist/commands/schedule/pause.js +2 -1
- package/dist/commands/schedule/resume.js +2 -1
- package/dist/commands/schedule/run-once.js +2 -1
- package/dist/commands/schedule/shared.d.ts +3 -0
- package/dist/commands/schedule/shared.js +35 -23
- package/dist/commands/schedule/update.js +2 -1
- package/dist/commands/script/index.d.ts +3 -0
- package/dist/commands/script/index.js +19 -0
- package/dist/commands/script/ls.d.ts +6 -0
- package/dist/commands/script/ls.js +20 -0
- package/dist/commands/script/schema.d.ts +5 -0
- package/dist/commands/script/schema.js +13 -0
- package/dist/commands/script/shared.d.ts +11 -0
- package/dist/commands/script/shared.js +59 -0
- package/dist/commands/script/start.d.ts +6 -0
- package/dist/commands/script/start.js +23 -0
- package/dist/commands/script/stop.d.ts +6 -0
- package/dist/commands/script/stop.js +23 -0
- package/dist/commands/workspace/archive.d.ts +12 -0
- package/dist/commands/workspace/archive.js +41 -0
- package/dist/commands/workspace/create.d.ts +49 -0
- package/dist/commands/workspace/create.js +114 -0
- package/dist/commands/workspace/index.d.ts +3 -0
- package/dist/commands/workspace/index.js +30 -0
- package/dist/commands/workspace/ls.d.ts +7 -0
- package/dist/commands/workspace/ls.js +28 -0
- package/dist/commands/workspace/shared.d.ts +12 -0
- package/dist/commands/workspace/shared.js +20 -0
- package/dist/output/pairing.d.ts +8 -0
- package/dist/output/pairing.js +24 -0
- package/dist/utils/client.d.ts +9 -0
- package/dist/utils/client.js +9 -0
- package/dist/utils/duration.d.ts +1 -1
- package/dist/utils/duration.js +8 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
518
|
+
const host = resolveTcpHostFromListen(state.ownedListen);
|
|
514
519
|
if (!host) {
|
|
515
520
|
return {
|
|
516
521
|
requested: false,
|
|
517
|
-
reason:
|
|
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
|
-
|
|
74
|
-
|
|
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,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
|