@hyperdrive.bot/paseo-cli 0.3.41 → 0.3.43

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.
@@ -1,4 +1,4 @@
1
- import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
1
+ import { connectToDaemon, getDaemonHost, resolveAgentViaDaemon } from "../../utils/client.js";
2
2
  /** Schema for archive command output */
3
3
  export const archiveSchema = {
4
4
  idField: "agentId",
@@ -39,21 +39,20 @@ export async function runArchiveCommand(agentIdArg, options, _command) {
39
39
  throw error;
40
40
  }
41
41
  try {
42
- const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
43
- const agents = agentsPayload.entries.map((entry) => entry.agent);
44
- const agentId = resolveAgentId(agentIdArg, agents);
45
- if (!agentId) {
42
+ // Resolve through the daemon, not through a page of `fetchAgents`: that rpc
43
+ // caps a page at 200 entries, so matching client-side against one page could
44
+ // not see agents outside the newest 200 and failed for most valid IDs here.
45
+ const resolution = await resolveAgentViaDaemon(client, agentIdArg);
46
+ if (!resolution.ok) {
46
47
  const error = {
47
48
  code: "AGENT_NOT_FOUND",
48
49
  message: `Agent not found: ${agentIdArg}`,
49
- details: 'Use "paseo ls" to list available agents',
50
+ details: `${resolution.error}\nUse "paseo ls" to list available agents`,
50
51
  };
51
52
  throw error;
52
53
  }
53
- const agent = agents.find((entry) => entry.id === agentId);
54
- if (!agent) {
55
- throw new Error(`Resolved agent missing from fetched agents: ${agentId}`);
56
- }
54
+ const agent = resolution.agent;
55
+ const agentId = agent.id;
57
56
  // Check if agent is already archived
58
57
  if (agent.archivedAt) {
59
58
  const error = {
@@ -1,4 +1,4 @@
1
- import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
1
+ import { connectToDaemon, getDaemonHost, resolveAgentViaDaemon } from "../../utils/client.js";
2
2
  export const reloadSchema = {
3
3
  idField: "agentId",
4
4
  columns: [
@@ -36,17 +36,19 @@ export async function runReloadCommand(agentIdArg, options, _command) {
36
36
  throw error;
37
37
  }
38
38
  try {
39
- const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
40
- const agents = agentsPayload.entries.map((entry) => entry.agent);
41
- const agentId = resolveAgentId(agentIdArg, agents);
42
- if (!agentId) {
39
+ // Resolve through the daemon, not through a page of `fetchAgents`: that rpc
40
+ // caps a page at 200 entries, so matching client-side against one page could
41
+ // not see agents outside the newest 200 and failed for most valid IDs here.
42
+ const resolution = await resolveAgentViaDaemon(client, agentIdArg);
43
+ if (!resolution.ok) {
43
44
  const error = {
44
45
  code: "AGENT_NOT_FOUND",
45
46
  message: `Agent not found: ${agentIdArg}`,
46
- details: 'Use "paseo ls" to list available agents',
47
+ details: `${resolution.error}\nUse "paseo ls" to list available agents`,
47
48
  };
48
49
  throw error;
49
50
  }
51
+ const agentId = resolution.agent.id;
50
52
  const result = await client.refreshAgent(agentId);
51
53
  await client.close();
52
54
  return {
@@ -32,6 +32,7 @@ export function createDaemonCommand() {
32
32
  .option("--home <path>", "Paseo home directory (default: ~/.paseo)")
33
33
  .option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
34
34
  .option("--force", "Send SIGKILL if graceful stop times out")
35
+ .option("--force-detached", "Restart detached even when the daemon is managed by an init system (systemd)")
35
36
  .option("--listen <listen>", "Listen target for restarted daemon (host:port, port, or unix socket)")
36
37
  .option("--port <port>", "Port for restarted daemon listen target")
37
38
  .option("--no-relay", "Disable relay on restarted daemon")
@@ -1,4 +1,5 @@
1
- import { startLocalDaemonDetached, stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS, } from "./local-daemon.js";
1
+ import { startLocalDaemonDetached, stopLocalDaemon, resolveLocalDaemonState, DEFAULT_STOP_TIMEOUT_MS, } from "./local-daemon.js";
2
+ import { detectServiceManagement } from "./service-manager.js";
2
3
  const restartResultSchema = {
3
4
  idField: "action",
4
5
  columns: [
@@ -47,10 +48,53 @@ function toStartOptions(options) {
47
48
  }
48
49
  return startOptions;
49
50
  }
51
+ /**
52
+ * Refuse to restart a daemon that an init system owns.
53
+ *
54
+ * `stop` + `startLocalDaemonDetached` is wrong twice under a service manager:
55
+ * the clean stop is not a failure, so a `Restart=on-failure` unit never fires
56
+ * and the replacement runs detached OUTSIDE the unit (systemd then reports the
57
+ * service as stopped while a daemon is in fact running); and the detached
58
+ * process inherits the invoking shell's environment instead of the unit's
59
+ * `Environment=`/`EnvironmentFile=`, so variables like PASEO_HOME are silently
60
+ * lost. Losing PASEO_HOME disables PTY recording with no diagnostic at all.
61
+ */
62
+ function assertNotServiceManaged(startOptions) {
63
+ const state = resolveLocalDaemonState({ home: startOptions.home });
64
+ if (!state.running || !state.pidInfo) {
65
+ return;
66
+ }
67
+ const managed = detectServiceManagement(state.pidInfo.pid);
68
+ if (!managed) {
69
+ return;
70
+ }
71
+ const error = {
72
+ code: "DAEMON_SERVICE_MANAGED",
73
+ message: `Daemon PID ${state.pidInfo.pid} is managed by ${managed.manager} unit ${managed.unit}`,
74
+ details: [
75
+ `Restart it through its service manager instead:`,
76
+ ` ${managed.restartCommand}`,
77
+ "",
78
+ "Restarting it here would stop the unit cleanly (so the unit's Restart= policy",
79
+ "never fires) and spawn a detached replacement outside it. The service manager",
80
+ "would report the service as stopped while a daemon kept running, and the",
81
+ "replacement would inherit this shell's environment rather than the unit's",
82
+ "Environment=/EnvironmentFile= settings, silently dropping variables such as",
83
+ "PASEO_HOME.",
84
+ "",
85
+ "Pass --force-detached to restart detached anyway.",
86
+ ].join("\n"),
87
+ };
88
+ throw error;
89
+ }
50
90
  export async function runRestartCommand(options, _command) {
51
91
  const timeoutMs = parseTimeoutMs(options.timeout);
52
92
  const force = options.force === true;
93
+ const forceDetached = options.forceDetached === true;
53
94
  const startOptions = toStartOptions(options);
95
+ if (!forceDetached) {
96
+ assertNotServiceManaged(startOptions);
97
+ }
54
98
  try {
55
99
  let stopResult;
56
100
  try {
@@ -88,6 +132,9 @@ export async function runRestartCommand(options, _command) {
88
132
  };
89
133
  }
90
134
  catch (err) {
135
+ if (err && typeof err === "object" && "code" in err) {
136
+ throw err;
137
+ }
91
138
  const message = err instanceof Error ? err.message : String(err);
92
139
  const error = {
93
140
  code: "RESTART_FAILED",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Describes an init-system unit that owns the running daemon process.
3
+ */
4
+ export interface ServiceManagement {
5
+ manager: "systemd";
6
+ /** Leaf unit name, e.g. "paseo.service". */
7
+ unit: string;
8
+ /** True when the unit is a per-user unit, i.e. `systemctl --user`. */
9
+ userScoped: boolean;
10
+ /** The command that restarts the daemon through its service manager. */
11
+ restartCommand: string;
12
+ }
13
+ /**
14
+ * Parse a `/proc/<pid>/cgroup` body into the service unit that owns the process.
15
+ *
16
+ * Handles both cgroup layouts, since the path is always the last colon-delimited
17
+ * field:
18
+ * v2: `0::/system.slice/paseo.service`
19
+ * v1: `1:name=systemd:/system.slice/paseo.service`
20
+ *
21
+ * Only a `.service` leaf counts as managed. A daemon the CLI started detached
22
+ * from a shell lands in a `.scope` (`session-5.scope`, `app-*.scope`) or the root
23
+ * cgroup, so it is never mistaken for a service.
24
+ */
25
+ export declare function parseServiceManagementFromCgroup(raw: string): ServiceManagement | null;
26
+ export interface DetectServiceManagementDeps {
27
+ /** Overridable for tests. Defaults to querying systemd. */
28
+ readUnitMainPid?: (unit: string, userScoped: boolean) => number | null;
29
+ /** Overridable for tests. Defaults to reading `/proc/<pid>/cgroup`. */
30
+ readCgroup?: (pid: number) => string | null;
31
+ }
32
+ /**
33
+ * Detect whether `pid` is the process an init-system unit manages, rather than a
34
+ * detached process this CLI owns.
35
+ *
36
+ * The cgroup alone is NOT sufficient. Every child inherits its parent's cgroup,
37
+ * so a daemon started from a shell that is itself running inside a unit (an
38
+ * agent shell under `paseo.service`, a CI job under a service) would report that
39
+ * unit even though the daemon is an ordinary detached process. The unit's
40
+ * MainPID is the discriminator, so a candidate from the cgroup is confirmed
41
+ * against it and dropped when it does not match.
42
+ *
43
+ * Returns null on anything less than a positive confirmation: a non-Linux
44
+ * platform, an unreadable cgroup, a non-service cgroup, or a host where
45
+ * `systemctl` cannot answer. Refusing to restart is the disruptive outcome, so
46
+ * uncertainty resolves to "not managed" and the normal path proceeds.
47
+ */
48
+ export declare function detectServiceManagement(pid: number, deps?: DetectServiceManagementDeps): ServiceManagement | null;
49
+ //# sourceMappingURL=service-manager.d.ts.map
@@ -0,0 +1,97 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ /** A `user@<uid>.service` segment is the per-user manager, never the daemon's own unit. */
4
+ const USER_MANAGER_UNIT = /^user@\d+\.service$/;
5
+ const SERVICE_UNIT = /^[A-Za-z0-9@:_.-]+\.service$/;
6
+ /**
7
+ * Parse a `/proc/<pid>/cgroup` body into the service unit that owns the process.
8
+ *
9
+ * Handles both cgroup layouts, since the path is always the last colon-delimited
10
+ * field:
11
+ * v2: `0::/system.slice/paseo.service`
12
+ * v1: `1:name=systemd:/system.slice/paseo.service`
13
+ *
14
+ * Only a `.service` leaf counts as managed. A daemon the CLI started detached
15
+ * from a shell lands in a `.scope` (`session-5.scope`, `app-*.scope`) or the root
16
+ * cgroup, so it is never mistaken for a service.
17
+ */
18
+ export function parseServiceManagementFromCgroup(raw) {
19
+ for (const line of raw.split("\n")) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed)
22
+ continue;
23
+ const pathPart = trimmed.slice(trimmed.lastIndexOf(":") + 1);
24
+ if (!pathPart.startsWith("/"))
25
+ continue;
26
+ const segments = pathPart.split("/").filter(Boolean);
27
+ const userScoped = segments.some((segment) => USER_MANAGER_UNIT.test(segment));
28
+ // Walk from the leaf inward: the daemon's own unit is the innermost one.
29
+ for (let i = segments.length - 1; i >= 0; i--) {
30
+ const segment = segments[i];
31
+ if (!SERVICE_UNIT.test(segment) || USER_MANAGER_UNIT.test(segment))
32
+ continue;
33
+ return {
34
+ manager: "systemd",
35
+ unit: segment,
36
+ userScoped,
37
+ restartCommand: userScoped
38
+ ? `systemctl --user restart ${segment}`
39
+ : `sudo systemctl restart ${segment}`,
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ function defaultReadCgroup(pid) {
46
+ try {
47
+ return readFileSync(`/proc/${pid}/cgroup`, "utf8");
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Ask systemd for a unit's MainPID. Returns null when systemd cannot answer. */
54
+ function readUnitMainPid(unit, userScoped) {
55
+ const args = userScoped
56
+ ? ["--user", "show", unit, "--property=MainPID", "--value"]
57
+ : ["show", unit, "--property=MainPID", "--value"];
58
+ const result = spawnSync("systemctl", args, { encoding: "utf8", timeout: 2000 });
59
+ if (result.error || result.status !== 0) {
60
+ return null;
61
+ }
62
+ const parsed = Number.parseInt((result.stdout ?? "").trim(), 10);
63
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
64
+ }
65
+ /**
66
+ * Detect whether `pid` is the process an init-system unit manages, rather than a
67
+ * detached process this CLI owns.
68
+ *
69
+ * The cgroup alone is NOT sufficient. Every child inherits its parent's cgroup,
70
+ * so a daemon started from a shell that is itself running inside a unit (an
71
+ * agent shell under `paseo.service`, a CI job under a service) would report that
72
+ * unit even though the daemon is an ordinary detached process. The unit's
73
+ * MainPID is the discriminator, so a candidate from the cgroup is confirmed
74
+ * against it and dropped when it does not match.
75
+ *
76
+ * Returns null on anything less than a positive confirmation: a non-Linux
77
+ * platform, an unreadable cgroup, a non-service cgroup, or a host where
78
+ * `systemctl` cannot answer. Refusing to restart is the disruptive outcome, so
79
+ * uncertainty resolves to "not managed" and the normal path proceeds.
80
+ */
81
+ export function detectServiceManagement(pid, deps = {}) {
82
+ if (process.platform !== "linux" || !Number.isInteger(pid) || pid <= 0) {
83
+ return null;
84
+ }
85
+ const readCgroup = deps.readCgroup ?? defaultReadCgroup;
86
+ const raw = readCgroup(pid);
87
+ if (!raw) {
88
+ return null;
89
+ }
90
+ const candidate = parseServiceManagementFromCgroup(raw);
91
+ if (!candidate) {
92
+ return null;
93
+ }
94
+ const lookup = deps.readUnitMainPid ?? readUnitMainPid;
95
+ return lookup(candidate.unit, candidate.userScoped) === pid ? candidate : null;
96
+ }
97
+ //# sourceMappingURL=service-manager.js.map
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import type { SingleResult } from "../../output/index.js";
3
3
  import { type ScheduleCommandOptions, type ScheduleRow } from "./shared.js";
4
4
  export interface ScheduleCreateOptions extends ScheduleCommandOptions {
5
+ at?: string;
5
6
  every?: string;
6
7
  cron?: string;
7
8
  timezone?: string;
@@ -5,6 +5,7 @@ export async function runCreateCommand(prompt, options, command) {
5
5
  const runNow = runNowSource === "cli" ? Boolean(options.runNow) : undefined;
6
6
  const input = parseScheduleCreateInput({
7
7
  prompt,
8
+ at: options.at,
8
9
  every: options.every,
9
10
  cron: options.cron,
10
11
  timezone: options.timezone,
@@ -16,7 +16,8 @@ export function createScheduleCommand() {
16
16
  .command("create")
17
17
  .description("Create a schedule")
18
18
  .argument("<prompt>", "Prompt to run on the schedule")
19
- .option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
19
+ .option("--at <when>", "Run once at an instant: ISO 8601 (2026-08-21T21:30:00-03:00) or a duration from now (90m)")
20
+ .option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h, 1d)")
20
21
  .option("--cron <expr>", "Cron cadence expression")
21
22
  .option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
22
23
  .option("--name <name>", "Optional schedule name")
@@ -13,6 +13,7 @@ export declare function formatTarget(target: ScheduleTarget | ScheduleListItem["
13
13
  export declare function formatDurationMs(durationMs: number): string;
14
14
  export declare function parseScheduleCreateInput(options: {
15
15
  prompt: string;
16
+ at?: string;
16
17
  every?: string;
17
18
  cron?: string;
18
19
  timezone?: string;
@@ -102,13 +102,7 @@ export function parseScheduleCreateInput(options) {
102
102
  message: "Schedule prompt cannot be empty",
103
103
  };
104
104
  }
105
- const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
106
- if (!cadence) {
107
- throw {
108
- code: "INVALID_CADENCE",
109
- message: "Specify exactly one of --every or --cron",
110
- };
111
- }
105
+ const { cadence, oneOff } = parseCreateCadence(options);
112
106
  const cwdInput = options.cwd?.trim();
113
107
  if (options.host !== undefined && !cwdInput) {
114
108
  throw {
@@ -116,7 +110,7 @@ export function parseScheduleCreateInput(options) {
116
110
  message: "--cwd is required when --host is specified (the local working directory will not exist on the remote daemon)",
117
111
  };
118
112
  }
119
- const runOnCreate = resolveRunOnCreate(options.runNow, cadence.type);
113
+ const { maxRuns, runOnCreate } = resolveCreateRunLimits(options, cadence, oneOff);
120
114
  const targetValue = options.target?.trim();
121
115
  const modeId = options.mode?.trim();
122
116
  const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
@@ -139,7 +133,6 @@ export function parseScheduleCreateInput(options) {
139
133
  hasExplicitNewAgentOption,
140
134
  createNewAgentTarget,
141
135
  });
142
- const maxRuns = options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
143
136
  const expiresAt = options.expiresIn === undefined
144
137
  ? undefined
145
138
  : new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
@@ -153,6 +146,33 @@ export function parseScheduleCreateInput(options) {
153
146
  ...(expiresAt ? { expiresAt } : {}),
154
147
  };
155
148
  }
149
+ // Split out of parseScheduleCreateInput to keep its branch count under the
150
+ // complexity cap: a one-off pins both limits, everything else defers to the
151
+ // existing --run-now rules.
152
+ function resolveCreateRunLimits(options, cadence, oneOff) {
153
+ const explicitMaxRuns = options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
154
+ if (!oneOff) {
155
+ return {
156
+ maxRuns: explicitMaxRuns,
157
+ runOnCreate: resolveRunOnCreate(options.runNow, cadence.type),
158
+ };
159
+ }
160
+ if (options.runNow !== undefined) {
161
+ throw {
162
+ code: "REDUNDANT_RUN_NOW",
163
+ message: "--run-now/--no-run-now cannot be combined with --at",
164
+ details: "An --at schedule runs exactly once, at its instant",
165
+ };
166
+ }
167
+ if (explicitMaxRuns !== undefined && explicitMaxRuns !== 1) {
168
+ throw {
169
+ code: "INVALID_MAX_RUNS",
170
+ message: "--max-runs cannot be combined with --at",
171
+ details: "An --at schedule runs exactly once",
172
+ };
173
+ }
174
+ return { maxRuns: 1, runOnCreate: false };
175
+ }
156
176
  function resolveRunOnCreate(runNow, cadenceType) {
157
177
  if (runNow === true && cadenceType === "every") {
158
178
  throw {
@@ -205,6 +225,65 @@ export function parseScheduleUpdateInput(options) {
205
225
  ...(expiresAt !== undefined ? { expiresAt } : {}),
206
226
  };
207
227
  }
228
+ // A duration ("90m", "2h30m", "45" seconds) rather than a timestamp. Checked
229
+ // first so --at can accept both without Date parsing guessing at bare numbers.
230
+ const AT_DURATION_ONLY = /^(?:\d+|(?:\d+[smhd])+)$/;
231
+ function resolveAtInstant(at) {
232
+ if (AT_DURATION_ONLY.test(at)) {
233
+ return new Date(Date.now() + parseDuration(at));
234
+ }
235
+ const parsed = new Date(at);
236
+ if (Number.isNaN(parsed.getTime())) {
237
+ throw {
238
+ code: "INVALID_AT",
239
+ message: `Invalid --at value: ${at}`,
240
+ details: "Use an ISO 8601 instant (2026-08-21T21:30:00-03:00) or a duration from now (90m)",
241
+ };
242
+ }
243
+ return parsed;
244
+ }
245
+ /**
246
+ * `--at` is a one-off. It lowers to an `every` cadence sized to the distance to
247
+ * the instant, pinned to a single run, deliberately NOT to a pinned cron: an
248
+ * `every` schedule stores its computed nextRunAt, so a daemon that was down at
249
+ * that moment fires it late on the next tick, which is what a reminder wants.
250
+ * A pinned cron would skip silently to the next calendar match a year out.
251
+ * Mirrors the MCP `at` input added in 0.3.41.
252
+ */
253
+ function parseCreateCadence(options) {
254
+ const at = options.at?.trim();
255
+ if (at) {
256
+ if (options.every !== undefined || options.cron !== undefined) {
257
+ throw {
258
+ code: "INVALID_CADENCE",
259
+ message: "Specify exactly one of --at, --every or --cron",
260
+ };
261
+ }
262
+ if (parseTimeZoneFlag(options.timezone) !== undefined) {
263
+ throw {
264
+ code: "INVALID_TIME_ZONE",
265
+ message: "--timezone can only be used with --cron",
266
+ };
267
+ }
268
+ const instant = resolveAtInstant(at);
269
+ const everyMs = instant.getTime() - Date.now();
270
+ if (everyMs <= 0) {
271
+ throw {
272
+ code: "INVALID_AT",
273
+ message: `--at must be in the future: ${instant.toISOString()} has already passed`,
274
+ };
275
+ }
276
+ return { cadence: { type: "every", everyMs }, oneOff: true };
277
+ }
278
+ const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
279
+ if (!cadence) {
280
+ throw {
281
+ code: "INVALID_CADENCE",
282
+ message: "Specify exactly one of --at, --every or --cron",
283
+ };
284
+ }
285
+ return { cadence, oneOff: false };
286
+ }
208
287
  function parseCadenceFromFlags(every, cron, timezone) {
209
288
  if (every !== undefined && cron !== undefined) {
210
289
  throw {
@@ -0,0 +1,49 @@
1
+ import type { LoopParamField, LoopParamValues } from "@hyperdrive.bot/paseo-protocol/fleet/params";
2
+ import type { WorkflowDefinitionIssue } from "@hyperdrive.bot/paseo-protocol/workflow/definition";
3
+ /**
4
+ * Reading the file `paseo workflow start` was pointed at, and turning `--input KEY=value`
5
+ * flags into typed values.
6
+ *
7
+ * Both jobs live here rather than in `start.ts` so they are testable without a daemon,
8
+ * a socket or a Commander instance. This module imports no daemon-connection helper at
9
+ * all, and must not start: every error it raises is raised before the CLI has any reason
10
+ * to talk to a daemon, which is what makes "a bad file never opens a socket" a property
11
+ * of the code rather than of the call order in `start.ts`. The import list above is the
12
+ * whole guarantee, and a grep for the connection helper is what enforces it.
13
+ */
14
+ export type WorkflowStartFile = {
15
+ kind: "definition";
16
+ raw: unknown;
17
+ } | {
18
+ kind: "task-graph";
19
+ raw: unknown;
20
+ };
21
+ /**
22
+ * Read and parse the file, and decide which of the two shapes it is.
23
+ *
24
+ * ONE parser. JSON is a subset of YAML 1.2, so a `.json` file needs no branch of its
25
+ * own, and the file extension is never consulted: a definition committed as `.json` and
26
+ * a task graph committed as `.yaml` both have to work, and an extension check would get
27
+ * each of them wrong in the direction that produces an error about the wrong thing.
28
+ */
29
+ export declare function readWorkflowStartFile(filePath: string): WorkflowStartFile;
30
+ export interface ParsedInputFlags {
31
+ values: LoopParamValues;
32
+ issues: WorkflowDefinitionIssue[];
33
+ }
34
+ /**
35
+ * Turn `--input KEY=VALUE` flags into values typed per the definition's declared inputs.
36
+ *
37
+ * Returns every issue rather than throwing on the first, mirroring `validateLoopParams`:
38
+ * an author with three bad flags fixes them once instead of re-running to discover the
39
+ * next one. Never returns early.
40
+ *
41
+ * It owns exactly three failure classes - flag syntax, undeclared key, and coercion to
42
+ * the declared type. It deliberately does NOT call `validateLoopParams`: `required`,
43
+ * `min`, `max` and `enum` membership are the compiler's single pass, and running them
44
+ * here as well would report the same mistake twice in one run. For the same reason an
45
+ * undeclared key is reported here AND withheld from `values`, so the compiler's own
46
+ * undeclared-key check never sees it and cannot double-report it.
47
+ */
48
+ export declare function parseInputFlags(flags: string[], fields: LoopParamField[]): ParsedInputFlags;
49
+ //# sourceMappingURL=definition-file.d.ts.map
@@ -0,0 +1,154 @@
1
+ import { readFileSync } from "node:fs";
2
+ import YAML from "yaml";
3
+ /**
4
+ * Read and parse the file, and decide which of the two shapes it is.
5
+ *
6
+ * ONE parser. JSON is a subset of YAML 1.2, so a `.json` file needs no branch of its
7
+ * own, and the file extension is never consulted: a definition committed as `.json` and
8
+ * a task graph committed as `.yaml` both have to work, and an extension check would get
9
+ * each of them wrong in the direction that produces an error about the wrong thing.
10
+ */
11
+ export function readWorkflowStartFile(filePath) {
12
+ let parsed;
13
+ try {
14
+ const raw = readFileSync(filePath, "utf-8");
15
+ parsed = YAML.parse(raw);
16
+ }
17
+ catch (error) {
18
+ // An unreadable or unparseable file has no top-level `version` key, so the rule
19
+ // below already classifies it as a task graph. Raising the pre-existing task-graph
20
+ // error here is what keeps today's behaviour for the malformed-JSON case.
21
+ throw {
22
+ code: "WORKFLOW_GRAPH_INVALID",
23
+ message: `Could not read/parse task graph at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
24
+ details: "Expected a JSON file matching the daemon's task-graph shape.",
25
+ };
26
+ }
27
+ // `Object.hasOwn`, never truthiness: `version: 0`, `version: null` and `version: "1"`
28
+ // are all definitions their author got wrong, and they must reach the definition
29
+ // parser's version gate. A truthiness check would send them down the task-graph
30
+ // branch, which reports a graph-shape error about a file nobody wrote as a graph.
31
+ const isDefinition = typeof parsed === "object" &&
32
+ parsed !== null &&
33
+ !Array.isArray(parsed) &&
34
+ Object.hasOwn(parsed, "version");
35
+ return isDefinition ? { kind: "definition", raw: parsed } : { kind: "task-graph", raw: parsed };
36
+ }
37
+ /** The six spellings `boolean` accepts, listed once so the check and the message agree. */
38
+ const TRUE_SPELLINGS = ["true", "1", "yes"];
39
+ const FALSE_SPELLINGS = ["false", "0", "no"];
40
+ /**
41
+ * Turn `--input KEY=VALUE` flags into values typed per the definition's declared inputs.
42
+ *
43
+ * Returns every issue rather than throwing on the first, mirroring `validateLoopParams`:
44
+ * an author with three bad flags fixes them once instead of re-running to discover the
45
+ * next one. Never returns early.
46
+ *
47
+ * It owns exactly three failure classes - flag syntax, undeclared key, and coercion to
48
+ * the declared type. It deliberately does NOT call `validateLoopParams`: `required`,
49
+ * `min`, `max` and `enum` membership are the compiler's single pass, and running them
50
+ * here as well would report the same mistake twice in one run. For the same reason an
51
+ * undeclared key is reported here AND withheld from `values`, so the compiler's own
52
+ * undeclared-key check never sees it and cannot double-report it.
53
+ */
54
+ export function parseInputFlags(flags, fields) {
55
+ const values = {};
56
+ const issues = [];
57
+ const byKey = new Map(fields.map((field) => [field.key, field]));
58
+ // Sorted once: the declared-key list is embedded in an error message, and a message
59
+ // whose word order depends on declaration order is not assertable.
60
+ const declaredKeys = [...byKey.keys()].sort();
61
+ const declaredSuffix = declaredKeys.length > 0
62
+ ? `Declared inputs are: ${declaredKeys.join(", ")}.`
63
+ : "This definition declares no inputs.";
64
+ for (const flag of flags) {
65
+ // First `=` only, so a value containing `=` survives intact: NOTE=a=b is "a=b".
66
+ const eq = flag.indexOf("=");
67
+ if (eq === -1) {
68
+ issues.push({
69
+ path: "--input",
70
+ message: `"${flag}" is not KEY=VALUE. Pass an input as --input KEY=VALUE.`,
71
+ });
72
+ continue;
73
+ }
74
+ const key = flag.slice(0, eq).trim();
75
+ const text = flag.slice(eq + 1);
76
+ const field = byKey.get(key);
77
+ if (!field) {
78
+ issues.push({
79
+ path: `inputs[${key}]`,
80
+ message: `"${key}" is not an input this definition declares. ${declaredSuffix}`,
81
+ });
82
+ continue;
83
+ }
84
+ const coerced = coerceInputValue(field, text);
85
+ if (!coerced.ok) {
86
+ issues.push({ path: `inputs[${key}]`, message: coerced.message });
87
+ continue;
88
+ }
89
+ // Last wins on a repeated key, by plain assignment. Repeating a flag is how every
90
+ // other repeatable option in this CLI behaves and is not an issue on its own.
91
+ values[key] = coerced.value;
92
+ }
93
+ return { values, issues };
94
+ }
95
+ /**
96
+ * Coerce one flag's text to one field's declared type.
97
+ *
98
+ * A `switch` over the closed type vocabulary rather than a chain of `if`s, so every
99
+ * branch is forced to name its own failure text and a new param type fails to compile
100
+ * here instead of silently falling through to "string".
101
+ */
102
+ function coerceInputValue(field, text) {
103
+ switch (field.type) {
104
+ case "string":
105
+ // Verbatim, not trimmed: leading or trailing space in a value destined for a
106
+ // prompt is the author's business, not the CLI's.
107
+ return { ok: true, value: text };
108
+ case "enum":
109
+ // Also verbatim. Membership against `options` is the compiler's check, so a bad
110
+ // choice is reported once, there, with the allowed values listed.
111
+ return { ok: true, value: text };
112
+ case "number":
113
+ return coerceNumber(field, text);
114
+ case "boolean":
115
+ return coerceBoolean(field, text);
116
+ case "string-list":
117
+ return {
118
+ ok: true,
119
+ value: text
120
+ .trim()
121
+ .split(",")
122
+ .map((entry) => entry.trim())
123
+ .filter((entry) => entry.length > 0),
124
+ };
125
+ }
126
+ }
127
+ function coerceNumber(field, text) {
128
+ const trimmed = text.trim();
129
+ // The empty check comes FIRST and that ordering is the whole point: `Number("")` is
130
+ // `0`, so `--input COUNT=` would otherwise launch a workflow with a count of zero the
131
+ // author never typed. An empty number is not a value; an omitted flag is.
132
+ if (trimmed.length === 0) {
133
+ return { ok: false, message: `${field.label} was passed with no value; expected a number.` };
134
+ }
135
+ const parsed = Number(trimmed);
136
+ if (!Number.isFinite(parsed)) {
137
+ return { ok: false, message: `${field.label} must be a number; got "${text}".` };
138
+ }
139
+ return { ok: true, value: parsed };
140
+ }
141
+ function coerceBoolean(field, text) {
142
+ const normalized = text.trim().toLowerCase();
143
+ if (TRUE_SPELLINGS.includes(normalized)) {
144
+ return { ok: true, value: true };
145
+ }
146
+ if (FALSE_SPELLINGS.includes(normalized)) {
147
+ return { ok: true, value: false };
148
+ }
149
+ return {
150
+ ok: false,
151
+ message: `${field.label} must be one of: ${[...TRUE_SPELLINGS, ...FALSE_SPELLINGS].join(", ")}; got "${text}".`,
152
+ };
153
+ }
154
+ //# sourceMappingURL=definition-file.js.map
@@ -12,6 +12,7 @@ export interface WorkflowStartOptions extends CommandOptions {
12
12
  cwd?: string;
13
13
  title?: string;
14
14
  detach?: boolean;
15
+ input?: string[];
15
16
  }
16
17
  export declare function addWorkflowStartOptions(command: Command): Command;
17
18
  /**
@@ -21,6 +22,6 @@ export declare function addWorkflowStartOptions(command: Command): Command;
21
22
  */
22
23
  export declare function buildWorkflowStatusUrl(host: string, workflowId: string): string;
23
24
  export type WorkflowStartResult = SingleResult<StartRow>;
24
- export declare function runWorkflowStartCommand(graphPath: string, options: WorkflowStartOptions, _command: Command): Promise<WorkflowStartResult>;
25
+ export declare function runWorkflowStartCommand(filePath: string, options: WorkflowStartOptions, _command: Command): Promise<WorkflowStartResult>;
25
26
  export {};
26
27
  //# sourceMappingURL=start.d.ts.map
@@ -1,6 +1,11 @@
1
- import { readFileSync } from "node:fs";
2
- import { WorkflowTaskGraphSchema } from "@hyperdrive.bot/paseo-protocol/messages";
1
+ import { randomUUID } from "node:crypto";
2
+ import { WorkflowTaskGraphSchema, } from "@hyperdrive.bot/paseo-protocol/messages";
3
+ import { defaultLoopParams } from "@hyperdrive.bot/paseo-protocol/fleet/params";
4
+ import { parseWorkflowDefinition, WorkflowDefinitionError, } from "@hyperdrive.bot/paseo-protocol/workflow/definition";
5
+ import { compileWorkflowDefinition } from "@hyperdrive.bot/paseo-protocol/workflow/compile";
3
6
  import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
7
+ import { collectMultiple } from "../../utils/command-options.js";
8
+ import { parseInputFlags, readWorkflowStartFile } from "./definition-file.js";
4
9
  const startSchema = {
5
10
  idField: "workflowId",
6
11
  columns: [
@@ -16,8 +21,9 @@ const startSchema = {
16
21
  };
17
22
  export function addWorkflowStartOptions(command) {
18
23
  return command
19
- .description("Launch a pre-built task-graph into the daemon (thin client)")
20
- .argument("<graph>", "Path to a task-graph JSON file")
24
+ .description("Launch a workflow definition (compiled here) or a pre-built task-graph into the daemon")
25
+ .argument("<file>", "Path to a workflow definition (YAML or JSON) or a pre-built task-graph JSON file")
26
+ .option("--input <key=value>", "Definition input value (repeatable)", collectMultiple, [])
21
27
  .option("--provider <provider>", "Agent provider each child runs (default: claude)")
22
28
  .option("--model <model>", "Optional provider model each child is spawned with")
23
29
  .option("--cwd <dir>", "Working directory all child agents inherit (default: cwd)")
@@ -46,21 +52,95 @@ export function buildWorkflowStatusUrl(host, workflowId) {
46
52
  const scheme = trimmed.includes("ssl=true") ? "https" : "http";
47
53
  return `${scheme}://${authority}/api/workflows/${workflowId}`;
48
54
  }
49
- export async function runWorkflowStartCommand(graphPath, options, _command) {
50
- // Parse + validate the task-graph file client-side for a clean error message;
51
- // the daemon re-validates against the same schema at the parse boundary.
52
- let graph;
55
+ /** One rendering of an issue list into `CommandError.details`, so the throw sites cannot drift. */
56
+ function issuesToDetails(issues) {
57
+ return issues.map((issue) => `${issue.path}: ${issue.message}`);
58
+ }
59
+ /**
60
+ * The pre-built path: today's behaviour, unchanged, plus one refusal.
61
+ *
62
+ * `--input` here is an error rather than a no-op. A task graph has no `inputs:`
63
+ * vocabulary, so accepting the flag and discarding it would read as configured and
64
+ * behave as absent — the exact silent acceptance this command exists to refuse.
65
+ */
66
+ function resolveTaskGraph(filePath, raw, inputFlags) {
67
+ if (inputFlags.length > 0) {
68
+ throw {
69
+ code: "WORKFLOW_INPUT_INVALID",
70
+ message: `--input is only supported for workflow definitions; ${filePath} is a pre-built task graph (no top-level "version" key).`,
71
+ details: inputFlags,
72
+ };
73
+ }
53
74
  try {
54
- const raw = readFileSync(graphPath, "utf-8");
55
- graph = WorkflowTaskGraphSchema.parse(JSON.parse(raw));
75
+ return { graph: WorkflowTaskGraphSchema.parse(raw) };
56
76
  }
57
77
  catch (error) {
58
78
  throw {
59
79
  code: "WORKFLOW_GRAPH_INVALID",
60
- message: `Could not read/parse task graph at ${graphPath}: ${error instanceof Error ? error.message : String(error)}`,
80
+ message: `Could not read/parse task graph at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
61
81
  details: "Expected a JSON file matching the daemon's task-graph shape.",
62
82
  };
63
83
  }
84
+ }
85
+ /** Parse the definition, read the flags, compile — in that order, each reporting every issue. */
86
+ function resolveDefinition(filePath, raw, inputFlags, options) {
87
+ const parsed = parseWorkflowDefinition(raw);
88
+ if (!parsed.ok) {
89
+ throw {
90
+ code: "WORKFLOW_DEFINITION_INVALID",
91
+ message: `Workflow definition at ${filePath} is invalid (${parsed.issues.length} issue(s)).`,
92
+ details: issuesToDetails(parsed.issues),
93
+ };
94
+ }
95
+ const definition = parsed.definition;
96
+ const flags = parseInputFlags(inputFlags, definition.inputs);
97
+ if (flags.issues.length > 0) {
98
+ throw {
99
+ code: "WORKFLOW_INPUT_INVALID",
100
+ message: `Could not read --input values for ${filePath} (${flags.issues.length} issue(s)).`,
101
+ details: issuesToDetails(flags.issues),
102
+ };
103
+ }
104
+ // Flags on the RIGHT: a supplied --input always beats a declared default.
105
+ const values = { ...defaultLoopParams({ fields: definition.inputs }), ...flags.values };
106
+ try {
107
+ // The compiler is pure by contract, so the three ambient values it needs are
108
+ // stamped here — the CLI is the only layer allowed to read a clock, a uuid or the
109
+ // process cwd. `session.id` is NOT the workflow id; the daemon mints its own.
110
+ return compileWorkflowDefinition({
111
+ definition,
112
+ values,
113
+ session: {
114
+ id: randomUUID(),
115
+ createdAt: new Date().toISOString(),
116
+ outputDirectory: options.cwd ?? process.cwd(),
117
+ },
118
+ });
119
+ }
120
+ catch (error) {
121
+ // Anything that is not a definition error is a genuine bug and must keep its stack
122
+ // trace rather than be relabelled as user error.
123
+ if (!(error instanceof WorkflowDefinitionError))
124
+ throw error;
125
+ // One deterministic routing rule: all-`inputs[` paths means the values were wrong,
126
+ // anything else means the definition was. Either way every issue lands in `details`.
127
+ const allInputIssues = error.issues.every((issue) => issue.path.startsWith("inputs["));
128
+ throw {
129
+ code: allInputIssues ? "WORKFLOW_INPUT_INVALID" : "WORKFLOW_DEFINITION_INVALID",
130
+ message: `Could not compile workflow definition at ${filePath} (${error.issues.length} issue(s)).`,
131
+ details: issuesToDetails(error.issues),
132
+ };
133
+ }
134
+ }
135
+ export async function runWorkflowStartCommand(filePath, options, _command) {
136
+ // Read, compile and validate entirely client-side, BEFORE any socket is opened: a bad
137
+ // definition must fail with its issue list even when the daemon is down. The daemon
138
+ // re-validates the graph against the same schema at its own parse boundary.
139
+ const file = readWorkflowStartFile(filePath);
140
+ const inputFlags = options.input ?? [];
141
+ const { graph, agentPresets } = file.kind === "task-graph"
142
+ ? resolveTaskGraph(filePath, file.raw, inputFlags)
143
+ : resolveDefinition(filePath, file.raw, inputFlags, options);
64
144
  const host = getDaemonHost({ host: options.host });
65
145
  let client;
66
146
  try {
@@ -81,6 +161,8 @@ export async function runWorkflowStartCommand(graphPath, options, _command) {
81
161
  cwd: options.cwd ?? process.cwd(),
82
162
  ...(options.model ? { model: options.model } : {}),
83
163
  ...(options.title ? { title: options.title } : {}),
164
+ // Conditional so the task-graph branch's payload stays identical to today's.
165
+ ...(agentPresets && Object.keys(agentPresets).length > 0 ? { agentPresets } : {}),
84
166
  });
85
167
  await client.close();
86
168
  return {
@@ -1,3 +1,4 @@
1
+ import type { AgentSnapshotPayload } from "@hyperdrive.bot/paseo-protocol/messages";
1
2
  import { DaemonClient } from "@hyperdrive.bot/paseo-client/internal/daemon-client";
2
3
  export interface ConnectOptions {
3
4
  host?: string;
@@ -40,5 +41,37 @@ interface AgentLike {
40
41
  * Returns the full agent ID if found, null otherwise.
41
42
  */
42
43
  export declare function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null;
44
+ /** Minimal surface of `DaemonClient` needed to resolve an agent identifier. */
45
+ export interface DaemonAgentFetcher {
46
+ fetchAgent(options: {
47
+ agentId: string;
48
+ }): Promise<{
49
+ agent: AgentSnapshotPayload;
50
+ } | null>;
51
+ }
52
+ /** Outcome of resolving an agent identifier through the daemon. */
53
+ export type DaemonAgentResolution = {
54
+ ok: true;
55
+ agent: AgentSnapshotPayload;
56
+ } | {
57
+ ok: false;
58
+ error: string;
59
+ };
60
+ /**
61
+ * Resolve an agent identifier (full ID, unambiguous ID prefix, or exact title)
62
+ * through the daemon and return the matching snapshot.
63
+ *
64
+ * Always prefer this over `fetchAgents()` + `resolveAgentId()`. `fetch_agents`
65
+ * is a PAGED rpc and the daemon caps a page at 200 entries by default
66
+ * (`request.page?.limit ?? 200` in the server session handler), so matching an
67
+ * identifier against a single unpaged response only ever searches the 200 most
68
+ * recently updated agents for that filter. On a host with more than 200 agents
69
+ * most valid IDs then resolve to "not found", and asking for
70
+ * `includeArchived: true` makes it strictly worse rather than better, because
71
+ * archived agents compete for the same 200 slots and push live ones out of the
72
+ * window. `fetch_agent` resolves server-side across the whole agent store with
73
+ * no cap, which is why `paseo agent inspect` never had this bug.
74
+ */
75
+ export declare function resolveAgentViaDaemon(client: DaemonAgentFetcher, idOrName: string): Promise<DaemonAgentResolution>;
43
76
  export {};
44
77
  //# sourceMappingURL=client.d.ts.map
@@ -327,4 +327,39 @@ export function resolveAgentId(idOrName, agents) {
327
327
  }
328
328
  return null;
329
329
  }
330
+ /**
331
+ * Resolve an agent identifier (full ID, unambiguous ID prefix, or exact title)
332
+ * through the daemon and return the matching snapshot.
333
+ *
334
+ * Always prefer this over `fetchAgents()` + `resolveAgentId()`. `fetch_agents`
335
+ * is a PAGED rpc and the daemon caps a page at 200 entries by default
336
+ * (`request.page?.limit ?? 200` in the server session handler), so matching an
337
+ * identifier against a single unpaged response only ever searches the 200 most
338
+ * recently updated agents for that filter. On a host with more than 200 agents
339
+ * most valid IDs then resolve to "not found", and asking for
340
+ * `includeArchived: true` makes it strictly worse rather than better, because
341
+ * archived agents compete for the same 200 slots and push live ones out of the
342
+ * window. `fetch_agent` resolves server-side across the whole agent store with
343
+ * no cap, which is why `paseo agent inspect` never had this bug.
344
+ */
345
+ export async function resolveAgentViaDaemon(client, idOrName) {
346
+ const trimmed = idOrName.trim();
347
+ if (!trimmed) {
348
+ return { ok: false, error: "Agent identifier cannot be empty" };
349
+ }
350
+ try {
351
+ const result = await client.fetchAgent({ agentId: trimmed });
352
+ if (!result) {
353
+ return { ok: false, error: `Agent not found: ${trimmed}` };
354
+ }
355
+ return { ok: true, agent: result.agent };
356
+ }
357
+ catch (err) {
358
+ // The daemon reports "not found" AND "ambiguous prefix/title" as an error
359
+ // payload on fetch_agent_response, which the client rethrows. Surfacing its
360
+ // message verbatim is an improvement on the old client-side resolver, which
361
+ // silently picked the first match for an ambiguous prefix.
362
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
363
+ }
364
+ }
330
365
  //# sourceMappingURL=client.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Parse duration string to milliseconds.
3
- * Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
3
+ * Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
4
4
  * If no unit is specified, assumes seconds.
5
5
  */
6
6
  export declare function parseDuration(input: string): number;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Parse duration string to milliseconds.
3
- * Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
3
+ * Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
4
4
  * If no unit is specified, assumes seconds.
5
5
  */
6
6
  export function parseDuration(input) {
@@ -11,7 +11,7 @@ export function parseDuration(input) {
11
11
  }
12
12
  // Parse duration with units
13
13
  let totalMs = 0;
14
- const regex = /(\d+)([smh])/g;
14
+ const regex = /(\d+)([smhd])/g;
15
15
  let match;
16
16
  let hasMatch = false;
17
17
  while ((match = regex.exec(trimmed)) !== null) {
@@ -28,10 +28,13 @@ export function parseDuration(input) {
28
28
  case "h":
29
29
  totalMs += value * 60 * 60 * 1000;
30
30
  break;
31
+ case "d":
32
+ totalMs += value * 24 * 60 * 60 * 1000;
33
+ break;
31
34
  }
32
35
  }
33
36
  if (!hasMatch) {
34
- throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
37
+ throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`);
35
38
  }
36
39
  return totalMs;
37
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperdrive.bot/paseo-cli",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "Paseo CLI - control your AI coding agents from the command line",
5
5
  "bin": {
6
6
  "paseo": "bin/paseo"
@@ -27,9 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^1.0.0",
30
- "@hyperdrive.bot/paseo-client": "0.3.41",
31
- "@hyperdrive.bot/paseo-protocol": "0.3.41",
32
- "@hyperdrive.bot/paseo-server": "0.3.41",
30
+ "@hyperdrive.bot/paseo-client": "0.3.43",
31
+ "@hyperdrive.bot/paseo-protocol": "0.3.43",
32
+ "@hyperdrive.bot/paseo-server": "0.3.43",
33
33
  "chalk": "^5.3.0",
34
34
  "commander": "^12.0.0",
35
35
  "mime-types": "^2.1.35",