@runuai/host 0.9.9 → 0.9.10

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/lib/agent-cli.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  * At task-up the host writes two files into the task workspace (bind-mounted at
5
5
  * /workspace, like skills — no docker cp/exec needed):
6
6
  * - `.uai/cli.mjs` — the self-contained agent CLI (Node ESM, fetch-only).
7
- * - `.uai/uai.json` — { apiUrl, token, permissions } the CLI reads.
7
+ * - `.uai/uai.json` — shared, tokenless { apiUrl } config the CLI reads.
8
8
  *
9
- * The token is minted here (host-side) carrying the task id, its owner user, and
10
- * the UNION of the roster's permissions; the cloud verifies it. Agents invoke it
11
- * as `node /workspace/.uai/cli.mjs <cmd>` (surfaced in the system preamble).
9
+ * A token is minted here per agent (host-side), carrying the task id, its owner
10
+ * user, that agent's identity, and only that agent's permissions; the cloud
11
+ * verifies it. Agents invoke the CLI as `node /workspace/.uai/cli.mjs <cmd>`
12
+ * (surfaced in the system preamble).
12
13
  */
13
14
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
15
  import { resolve } from "node:path";
@@ -45,7 +46,7 @@ export function loadTaskCliSecret(taskId: string): string | null {
45
46
  }
46
47
  }
47
48
 
48
- /** The union of a roster's permissions (ADR-048) the task's effective powers. */
49
+ /** The union used only to decide whether this task needs the shared CLI file. */
49
50
  export function rosterPermissions(roster: RosterAgent[]): string[] {
50
51
  return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
51
52
  }
@@ -253,6 +254,29 @@ async function main() {
253
254
  })).task);
254
255
  break;
255
256
  }
257
+ case "task close": {
258
+ const allowed = new Set(["status", "reason"]);
259
+ const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
260
+ if (pos.length || unknown.length) {
261
+ console.error("uai: task close accepts only --status and --reason");
262
+ process.exit(1);
263
+ }
264
+ const status = flags.status;
265
+ if (status !== "finished" && status !== "canceled") {
266
+ console.error("uai: task close needs --status finished|canceled");
267
+ process.exit(1);
268
+ }
269
+ if (flags.reason === "true") {
270
+ console.error("uai: --reason needs text (quote multiword reasons)");
271
+ process.exit(1);
272
+ }
273
+ const closed = await api("POST", "/api/agent/tasks/close", {
274
+ status,
275
+ ...(flags.reason ? { reason: String(flags.reason) } : {}),
276
+ });
277
+ out("task closed as " + closed.status);
278
+ break;
279
+ }
256
280
  case "todo list": {
257
281
  const todos = (await api("GET", "/api/agent/todos")).todos || [];
258
282
  if (!todos.length) { out("punchlist is empty"); break; }
@@ -329,6 +353,7 @@ async function main() {
329
353
  " uai people list",
330
354
  " uai task list",
331
355
  " uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
356
+ ' uai task close --status finished|canceled [--reason "final report"]',
332
357
  " uai memory search <query>",
333
358
  " uai memory save <text> [--project id] [--tags a,b]",
334
359
  " uai memory delete <id>",
@@ -249,14 +249,28 @@ export class DurableProcess {
249
249
  this.exitHandlers.add(handler);
250
250
  }
251
251
 
252
- /** Serialise `value` as one JSONL line appended to the runner's inbox. */
252
+ /**
253
+ * Serialise `value` as one JSONL line appended to the runner's inbox.
254
+ * A runner-filtered metadata line immediately before it supplies the local
255
+ * timestamp without changing the protocol frame forwarded to the CLI. Old
256
+ * runners already ignore unknown `__uai` lines, so attached sessions remain
257
+ * wire-compatible across the host update.
258
+ *
259
+ * `__uai` MUST stay the first key of the metadata object. Both runners filter
260
+ * with `line.startsWith('{"__uai"')` — a byte prefix, not a parse — so
261
+ * serialising the keys in any other order emits `{"ts":…`, which the runner
262
+ * then forwards to the CLI's stdin as a user turn. No type in this file
263
+ * expresses that, and a `toEqual` assertion cannot see it; the raw-prefix
264
+ * check in `durable-proc.test.ts` is what pins it.
265
+ */
253
266
  writeLine(value: unknown): void {
254
267
  if (this.closed || this.detached) return;
255
268
  const json = JSON.stringify(value);
269
+ const inputMeta = JSON.stringify({ __uai: "input", ts: Date.now() });
256
270
  if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
257
271
  // Chain appends so concurrent writes can't interleave bytes.
258
272
  this.inboxChain = this.inboxChain
259
- .then(() => fsp.appendFile(this.inboxPath, `${json}\n`))
273
+ .then(() => fsp.appendFile(this.inboxPath, `${inputMeta}\n${json}\n`))
260
274
  .catch(() => {
261
275
  // Disk error — liveness checks will surface a dead session.
262
276
  });
@@ -22,8 +22,16 @@
22
22
  * feature, and each spawn ships the runner version matching this host.
23
23
  */
24
24
 
25
- import { copyFileSync, mkdirSync, statSync, promises as fsp } from "node:fs";
26
- import { join } from "node:path";
25
+ import {
26
+ copyFileSync,
27
+ mkdirSync,
28
+ renameSync,
29
+ rmSync,
30
+ statSync,
31
+ symlinkSync,
32
+ promises as fsp,
33
+ } from "node:fs";
34
+ import { basename, dirname, join } from "node:path";
27
35
 
28
36
  import { and, eq } from "drizzle-orm";
29
37
 
@@ -93,6 +101,7 @@ function durableEnabled(): boolean {
93
101
 
94
102
  export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
95
103
  if (!durableEnabled()) {
104
+ clearCurrentSession(opts.taskId, opts.agentId);
96
105
  const { command, args } = dockerExecArgs(
97
106
  opts.containerName,
98
107
  opts.cli,
@@ -157,6 +166,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
157
166
  debugLabel: opts.debugLabel,
158
167
  });
159
168
  proc.onExit(markClosed);
169
+ publishCurrentSession(row.sessionDir);
160
170
  return claimTail(tailKey, proc);
161
171
  }
162
172
 
@@ -194,6 +204,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
194
204
  console.warn(
195
205
  `[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
196
206
  );
207
+ clearCurrentSession(opts.taskId, opts.agentId);
197
208
  const { command, args } = dockerExecArgs(
198
209
  opts.containerName,
199
210
  opts.cli,
@@ -263,9 +274,49 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
263
274
  .run();
264
275
 
265
276
  proc.onExit(markClosed);
277
+ publishCurrentSession(sessionDir);
266
278
  return claimTail(tailKey, proc);
267
279
  }
268
280
 
281
+ /**
282
+ * Make the opaque generation directory discoverable from inside the task.
283
+ * The relative link resolves on both sides of the workspace bind mount. The
284
+ * pointer is diagnostic only: failure to publish it must not kill a session.
285
+ */
286
+ function publishCurrentSession(sessionDir: string): void {
287
+ const parent = dirname(sessionDir);
288
+ const current = join(parent, "current");
289
+ const pending = join(parent, `.current-${process.pid}-${Date.now()}`);
290
+ try {
291
+ symlinkSync(basename(sessionDir), pending, "dir");
292
+ renameSync(pending, current);
293
+ } catch {
294
+ try {
295
+ rmSync(pending, { force: true });
296
+ } catch {
297
+ // Best-effort navigation aid; the generation path remains authoritative.
298
+ }
299
+ }
300
+ }
301
+
302
+ /** A direct-pipe session must not leave an older durable inbox looking live. */
303
+ function clearCurrentSession(taskId: string, agentId: string): void {
304
+ try {
305
+ rmSync(
306
+ join(
307
+ taskWorkspaceDir(taskId),
308
+ ".uai",
309
+ "sessions",
310
+ agentId,
311
+ "current",
312
+ ),
313
+ { force: true },
314
+ );
315
+ } catch {
316
+ // Same best-effort rule as publishCurrentSession.
317
+ }
318
+ }
319
+
269
320
  function heartbeatFresh(sessionDir: string): boolean {
270
321
  try {
271
322
  return (
@@ -2805,9 +2805,10 @@ export function assembleFirstTurnPrompt(
2805
2805
 
2806
2806
  /**
2807
2807
  * Build the system preamble an agent gets on session start. It opens
2808
- * with how uai's channel works agents must know to hand off via
2809
- * `@mention`, since there is no `peer` command / shared tmux any more
2810
- * (ADR-008) then appends the project context and the agent's persona.
2808
+ * with how uai's channel works. Open-mode agents hand off via `@mention`
2809
+ * because there is no `peer` command / shared tmux (ADR-008); Secretary-mode
2810
+ * crew report through the designated Secretary instead. It then appends the
2811
+ * project context and the agent's persona.
2811
2812
  *
2812
2813
  * The persona / mission layers (project defaultPrompts, globalContext,
2813
2814
  * agent.defaultPrompt) live in the always-on system prompt so they apply
@@ -2832,6 +2833,12 @@ export function buildSystemPreamble(
2832
2833
  a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
2833
2834
  )
2834
2835
  .join(", ");
2836
+ const isSecretary =
2837
+ mode === "secretary" && secretaryAgentId === agent.id;
2838
+ const isSecretaryCrew =
2839
+ mode === "secretary" &&
2840
+ secretaryAgentId !== undefined &&
2841
+ !isSecretary;
2835
2842
  // ADR-049: with several humans in the chat, brief the agent on who they are
2836
2843
  // and how to address one specifically. Single-human tasks keep the original
2837
2844
  // wording byte-identical.
@@ -2839,8 +2846,14 @@ export function buildSystemPreamble(
2839
2846
  const humanList = (humans ?? [])
2840
2847
  .map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
2841
2848
  .join(", ");
2842
- const humanIntro = multiHuman
2849
+ const humanIntro = isSecretaryCrew
2843
2850
  ? [
2851
+ `The human-facing conversation is owned by @${secretaryAgentId}, the`,
2852
+ "designated Secretary. `@you` and human @handles in your replies remain",
2853
+ "visible as routing hints, but they do not notify a human directly.",
2854
+ ]
2855
+ : multiHuman
2856
+ ? [
2844
2857
  `SEVERAL humans share this channel: ${humanList}. Their messages`,
2845
2858
  "arrive prefixed with the sender's name so you can tell them apart.",
2846
2859
  "`@you` still works and reaches the human you're currently talking",
@@ -2855,8 +2868,8 @@ export function buildSystemPreamble(
2855
2868
  "just asked, acknowledgments — post in the channel WITHOUT",
2856
2869
  "@-mentioning; they can read the channel and don't need a ping for",
2857
2870
  "every message.",
2858
- ]
2859
- : [
2871
+ ]
2872
+ : [
2860
2873
  "The human you're working with is **@you**. @-mentioning them sends a",
2861
2874
  "NOTIFICATION, so use it sparingly — only when you actually need them: a",
2862
2875
  "decision you can't make, a blocker, an approval, or you've finished your",
@@ -2866,7 +2879,7 @@ export function buildSystemPreamble(
2866
2879
  "something they just asked, acknowledgments — post in the channel WITHOUT",
2867
2880
  "@-mentioning @you; they can read the channel and don't need a ping for",
2868
2881
  "every message. Do NOT reflexively end messages with @you.",
2869
- ];
2882
+ ];
2870
2883
  const projectLines =
2871
2884
  projects.length === 0
2872
2885
  ? ["(none mounted)"]
@@ -2874,8 +2887,6 @@ export function buildSystemPreamble(
2874
2887
  (p) =>
2875
2888
  `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
2876
2889
  );
2877
- const isSecretary =
2878
- mode === "secretary" && secretaryAgentId === agent.id;
2879
2890
  const dispatchActionBrief = [
2880
2891
  "When crew work is needed, run the Secretary CLI action:",
2881
2892
  `\`node ${CONTAINER_CLI_PATH} dispatch @agent [@agent…] \"instruction\"\``,
@@ -2911,6 +2922,22 @@ export function buildSystemPreamble(
2911
2922
  "`/workspace/.uai/chat.md`. Read it whenever you need that context;",
2912
2923
  "it's appended live, so re-read it for the latest.",
2913
2924
  ];
2925
+ const sessionInboxBrief =
2926
+ mode !== "secretary"
2927
+ ? []
2928
+ : isSecretary
2929
+ ? [
2930
+ "When durable sessions are active, their current inboxes are linked at",
2931
+ "`/workspace/.uai/sessions/<agent-id>/current/inbox.jsonl`.",
2932
+ 'An epoch-ms `{"__uai":"input","ts":…}` line precedes each',
2933
+ "unchanged CLI frame, so the adjacent pair identifies a wake input.",
2934
+ ]
2935
+ : [
2936
+ "When durable sessions are active, your current runner inbox is at",
2937
+ `\`/workspace/.uai/sessions/${agent.id}/current/inbox.jsonl\`.`,
2938
+ 'An epoch-ms `{"__uai":"input","ts":…}` line precedes each',
2939
+ "unchanged CLI frame, so the adjacent pair identifies a wake input.",
2940
+ ];
2914
2941
  const secretaryRoleBrief = isSecretary
2915
2942
  ? [
2916
2943
  "## Secretary role",
@@ -2939,7 +2966,14 @@ export function buildSystemPreamble(
2939
2966
  "instruction for each",
2940
2967
  "crew member you need. Do not merely say that someone else will answer.",
2941
2968
  ]
2942
- : [
2969
+ : isSecretaryCrew
2970
+ ? [
2971
+ "Handles in your dispatch or the backstage transcript are context,",
2972
+ "not proof that another recipient was notified. Answer only for your",
2973
+ "assigned part. If someone else should act, state who and what is",
2974
+ "needed; the Secretary decides whether to dispatch it.",
2975
+ ]
2976
+ : [
2943
2977
  "When a message already @-mentions several participants at once (the",
2944
2978
  "human asking the whole group, or a peer addressing multiple agents),",
2945
2979
  "it's a group broadcast — this is a GROUP CHAT and everyone named has",
@@ -2949,14 +2983,20 @@ export function buildSystemPreamble(
2949
2983
  "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2950
2984
  "the message only wakes them again and spirals into duplicate replies.",
2951
2985
  "Say your piece and stop.",
2952
- ];
2986
+ ];
2953
2987
  const handoffBrief = isSecretary
2954
2988
  ? [
2955
2989
  "When crew work finishes, synthesize the outcome for the human and make",
2956
2990
  "the next decision or blocker explicit. Do not abandon an unresolved",
2957
2991
  "request silently, and do not wake a peer for acknowledgments alone.",
2958
2992
  ]
2959
- : [
2993
+ : isSecretaryCrew
2994
+ ? [
2995
+ "When you finish, report the result, next decision, or blocker plainly",
2996
+ "to the Secretary; no @mention is needed. Do not assume a named peer",
2997
+ "was woken, and do not wait for a direct reply from one.",
2998
+ ]
2999
+ : [
2960
3000
  "Hand off when you finish your part of the work. When you've made",
2961
3001
  "and committed your changes, or completed a review, end your reply by",
2962
3002
  "@-mentioning the agent who should act next and telling them what you",
@@ -2966,7 +3006,7 @@ export function buildSystemPreamble(
2966
3006
  "peer needs to act, it's fine to stop; only @-mention @you if you need",
2967
3007
  "their input or are handing back finished work for them to act on. Don't",
2968
3008
  "prolong an agent-to-agent exchange just to fill silence.",
2969
- ];
3009
+ ];
2970
3010
  const checkInTranscriptBrief = isSecretary
2971
3011
  ? [
2972
3012
  "Read both transcript files named above, and speak ONLY if you have",
@@ -2974,11 +3014,13 @@ export function buildSystemPreamble(
2974
3014
  "PASS reply is discarded and never shown to anyone, so it is always a",
2975
3015
  "safe way to decline a turn.",
2976
3016
  ]
2977
- : [
3017
+ : isSecretaryCrew
3018
+ ? []
3019
+ : [
2978
3020
  "Read the transcript, and speak ONLY if you have something substantive to",
2979
3021
  "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2980
3022
  "never shown to anyone, so it is always a safe way to decline a turn.",
2981
- ];
3023
+ ];
2982
3024
  const workspaceBrief = isSecretary
2983
3025
  ? [
2984
3026
  "## Workspace layout",
@@ -3028,19 +3070,35 @@ export function buildSystemPreamble(
3028
3070
  "or @-mentioning a crew agent in prose does NOT wake them.",
3029
3071
  "The only way to hand crew work off is the structured `dispatch` action.",
3030
3072
  ]
3031
- : [
3073
+ : isSecretaryCrew
3074
+ ? [
3075
+ "You are a backstage crew agent in a Secretary-mode task channel.",
3076
+ `@${secretaryAgentId} is the designated Secretary and your sole`,
3077
+ "conversational routing point. Every completed reply you write is",
3078
+ `delivered once to @${secretaryAgentId}, whether it names @you, a`,
3079
+ "human, another crew agent, or nobody. Those names remain visible as",
3080
+ "routing hints, but they do not notify the human or wake a peer.",
3081
+ ]
3082
+ : [
3032
3083
  "You are one agent in a uai task chat channel, shared with the human",
3033
3084
  "and the other agents. To hand work to or ask another agent, mention",
3034
3085
  "it by id at the start of a line — e.g. `@codex please review the",
3035
3086
  "diff`. uai routes that message into that agent's input.",
3036
- ];
3087
+ ];
3037
3088
  const collaborationBrief = isSecretary
3038
3089
  ? [
3039
3090
  "Collaborate through deliberate dispatches. Each dispatch wakes a crew",
3040
3091
  "agent and costs a turn, so make every instruction concrete, self-contained,",
3041
3092
  "and necessary. Never dispatch greetings, thanks, or acknowledgments.",
3042
3093
  ]
3043
- : [
3094
+ : isSecretaryCrew
3095
+ ? [
3096
+ `Only a structured dispatch from @${secretaryAgentId} wakes a crew`,
3097
+ "agent. Work on the assignment you received. If someone else should",
3098
+ "act, state who and what is needed; the Secretary decides whether to",
3099
+ "dispatch it.",
3100
+ ]
3101
+ : [
3044
3102
  "An agent only receives a message when it is explicitly @-mentioned",
3045
3103
  "(or addressed by the human) — so always @-mention the agent (or @you)",
3046
3104
  "you mean. There is NO `peer` command and no shared tmux session;",
@@ -3059,7 +3117,7 @@ export function buildSystemPreamble(
3059
3117
  "don't @-mention back. And when there's no active task yet (intros, or",
3060
3118
  "you're waiting on the human), answer briefly and then wait — you don't",
3061
3119
  "need to @-mention anyone (including @you); they can see the channel.",
3062
- ];
3120
+ ];
3063
3121
  const channelConventionsBrief = isSecretary
3064
3122
  ? [
3065
3123
  "Your prose always goes to the human-facing lane; answer plainly without",
@@ -3068,13 +3126,19 @@ export function buildSystemPreamble(
3068
3126
  "You may occasionally receive a `[channel check-in]` asking you to catch",
3069
3127
  "up on the channel.",
3070
3128
  ]
3071
- : [
3129
+ : isSecretaryCrew
3130
+ ? [
3131
+ "Every reply returns to the Secretary whether or not it contains",
3132
+ "@mentions. Answer plainly; use names only when they help the",
3133
+ "Secretary understand who should hear or act on the result.",
3134
+ ]
3135
+ : [
3072
3136
  "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
3073
3137
  "uai hands it back to whoever prompted you — so when you're ANSWERING,",
3074
3138
  "just answer plainly; you don't need to re-mention the asker. Mention",
3075
3139
  "someone only to bring them in or hand work off. (2) You may occasionally",
3076
3140
  "receive a `[channel check-in]` asking you to catch up on the channel.",
3077
- ];
3141
+ ];
3078
3142
  const comms = [
3079
3143
  "## uai task channel",
3080
3144
  "",
@@ -3094,6 +3158,8 @@ export function buildSystemPreamble(
3094
3158
  "",
3095
3159
  ...transcriptBrief,
3096
3160
  "",
3161
+ ...sessionInboxBrief,
3162
+ ...(sessionInboxBrief.length > 0 ? [""] : []),
3097
3163
  ...secretaryRoleBrief,
3098
3164
  ...channelConventionsBrief,
3099
3165
  ...checkInTranscriptBrief,
@@ -3191,6 +3257,9 @@ export function buildSystemPreamble(
3191
3257
  (agent.permissions?.includes("tasks.create")
3192
3258
  ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
3193
3259
  : "") +
3260
+ (agent.permissions?.includes("tasks.close")
3261
+ ? ", `task close --status finished|canceled [--reason \"final report\"]`"
3262
+ : "") +
3194
3263
  (agent.permissions?.includes("memory.write")
3195
3264
  ? ", `memory save <text>`"
3196
3265
  : "") +
@@ -3227,6 +3296,17 @@ export function buildSystemPreamble(
3227
3296
  "`projects list` / `people list`.",
3228
3297
  ]
3229
3298
  : []),
3299
+ ...(agent.permissions?.includes("tasks.close")
3300
+ ? [
3301
+ "**Closing this task:** `task close` is task-wide, not a way to",
3302
+ "say that only your part is done. It stops every agent and",
3303
+ "destroys this container/session. Use it only when the ENTIRE",
3304
+ "task is terminal and the human's instructions authorize that",
3305
+ "outcome. Make it the final action of your turn and put the",
3306
+ "useful final summary in `--reason`, because your normal response",
3307
+ "may not be delivered after the container tears down.",
3308
+ ]
3309
+ : []),
3230
3310
  ...(agent.permissions?.includes("memory.read") ||
3231
3311
  agent.permissions?.includes("memory.write")
3232
3312
  ? [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/runner/runner.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  * <sessionDir>/runner.json pid, protocol, argv, startedAt
12
12
  *
13
13
  * Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
14
- * before handing lines to the protocol adapters. Control lines the host
15
- * appends to the inbox use the same shape (`{"__uai":"stop"}`); everything
16
- * else in the inbox goes to the CLI's stdin untouched.
14
+ * before handing lines to the protocol adapters. Host metadata/control lines
15
+ * in the inbox use the same shape (`input` timestamps and `stop`); the runner
16
+ * filters those, while every protocol frame goes to the CLI's stdin untouched.
17
17
  *
18
18
  * Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
19
19
  * at a tmp dir and any line-oriented fake CLI.
package/src/protocol.ts CHANGED
@@ -43,6 +43,14 @@ export const MAX_SECRETARY_DISPATCH_ID_CHARS = 256;
43
43
 
44
44
  export interface CommandContext {
45
45
  commandId: string;
46
+ /**
47
+ * Cloud-side deadline for this command, in ms. Optional and OFF by default:
48
+ * most commands (task-up especially) legitimately take minutes, so a blanket
49
+ * timeout would be wrong. When set, the cloud stops waiting AND forgets the
50
+ * pending entry — without that second half a silent host accumulates one
51
+ * dead resolver per retry.
52
+ */
53
+ timeoutMs?: number;
46
54
  }
47
55
 
48
56
  export type PermissionDecision = { kind: "accept" } | { kind: "decline" };