@runuai/host 0.9.8 → 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 (
@@ -1634,11 +1634,27 @@ export class Orchestrator {
1634
1634
  return remaining;
1635
1635
  }
1636
1636
 
1637
- /** Retire turn-local state owned by a runner generation before replacement. */
1637
+ /**
1638
+ * Retire turn-local state owned by a runner generation before replacement.
1639
+ *
1640
+ * A replacement's identity guard suppresses the old runner's eventual
1641
+ * terminal event. If the human already stopped that turn, close its cloud
1642
+ * boundary here and discard its replay prompt; otherwise credential refresh
1643
+ * could silently restart canceled work on the new generation.
1644
+ */
1638
1645
  private retireRunnerGeneration(channel: Channel, agentId: string): void {
1646
+ const aborted = channel.interrupted.delete(agentId);
1639
1647
  channel.activeTurns.delete(agentId);
1640
1648
  channel.openTurns.delete(agentId);
1641
- channel.interrupted.delete(agentId);
1649
+ if (aborted) {
1650
+ channel.lastPrompt.delete(agentId);
1651
+ this.emitHost({
1652
+ kind: "agent.turn_complete",
1653
+ taskId: channel.taskId,
1654
+ agentId,
1655
+ aborted: true,
1656
+ });
1657
+ }
1642
1658
  }
1643
1659
 
1644
1660
  private startPrompt(
@@ -1766,9 +1782,12 @@ export class Orchestrator {
1766
1782
  // An interrupted turn is a half-turn — its eventual turn_complete must not
1767
1783
  // hand the buffered fragment to @-mentioned peers. Flag it so the boundary
1768
1784
  // goes out `aborted` and the cloud discards the buffer. Only when the turn
1769
- // actually streamed output (openTurns): an idle-agent ESC must not mark
1770
- // the NEXT legitimate turn as aborted.
1771
- if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
1785
+ // is actually in flight (activeTurns): an idle-agent ESC must not mark the
1786
+ // NEXT legitimate turn as aborted, while a stop before the first output is
1787
+ // still a cancellation and must not be replayed by runner replacement.
1788
+ if ((channel.activeTurns.get(agentId) ?? 0) > 0) {
1789
+ channel.interrupted.add(agentId);
1790
+ }
1772
1791
  void session.interrupt();
1773
1792
  this.emitSystemNote(taskId, `Stopped @${agentId}.`);
1774
1793
  return { ok: true };
@@ -2786,9 +2805,10 @@ export function assembleFirstTurnPrompt(
2786
2805
 
2787
2806
  /**
2788
2807
  * Build the system preamble an agent gets on session start. It opens
2789
- * with how uai's channel works agents must know to hand off via
2790
- * `@mention`, since there is no `peer` command / shared tmux any more
2791
- * (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.
2792
2812
  *
2793
2813
  * The persona / mission layers (project defaultPrompts, globalContext,
2794
2814
  * agent.defaultPrompt) live in the always-on system prompt so they apply
@@ -2813,6 +2833,12 @@ export function buildSystemPreamble(
2813
2833
  a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
2814
2834
  )
2815
2835
  .join(", ");
2836
+ const isSecretary =
2837
+ mode === "secretary" && secretaryAgentId === agent.id;
2838
+ const isSecretaryCrew =
2839
+ mode === "secretary" &&
2840
+ secretaryAgentId !== undefined &&
2841
+ !isSecretary;
2816
2842
  // ADR-049: with several humans in the chat, brief the agent on who they are
2817
2843
  // and how to address one specifically. Single-human tasks keep the original
2818
2844
  // wording byte-identical.
@@ -2820,8 +2846,14 @@ export function buildSystemPreamble(
2820
2846
  const humanList = (humans ?? [])
2821
2847
  .map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
2822
2848
  .join(", ");
2823
- const humanIntro = multiHuman
2849
+ const humanIntro = isSecretaryCrew
2824
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
+ ? [
2825
2857
  `SEVERAL humans share this channel: ${humanList}. Their messages`,
2826
2858
  "arrive prefixed with the sender's name so you can tell them apart.",
2827
2859
  "`@you` still works and reaches the human you're currently talking",
@@ -2836,8 +2868,8 @@ export function buildSystemPreamble(
2836
2868
  "just asked, acknowledgments — post in the channel WITHOUT",
2837
2869
  "@-mentioning; they can read the channel and don't need a ping for",
2838
2870
  "every message.",
2839
- ]
2840
- : [
2871
+ ]
2872
+ : [
2841
2873
  "The human you're working with is **@you**. @-mentioning them sends a",
2842
2874
  "NOTIFICATION, so use it sparingly — only when you actually need them: a",
2843
2875
  "decision you can't make, a blocker, an approval, or you've finished your",
@@ -2847,7 +2879,7 @@ export function buildSystemPreamble(
2847
2879
  "something they just asked, acknowledgments — post in the channel WITHOUT",
2848
2880
  "@-mentioning @you; they can read the channel and don't need a ping for",
2849
2881
  "every message. Do NOT reflexively end messages with @you.",
2850
- ];
2882
+ ];
2851
2883
  const projectLines =
2852
2884
  projects.length === 0
2853
2885
  ? ["(none mounted)"]
@@ -2855,8 +2887,6 @@ export function buildSystemPreamble(
2855
2887
  (p) =>
2856
2888
  `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
2857
2889
  );
2858
- const isSecretary =
2859
- mode === "secretary" && secretaryAgentId === agent.id;
2860
2890
  const dispatchActionBrief = [
2861
2891
  "When crew work is needed, run the Secretary CLI action:",
2862
2892
  `\`node ${CONTAINER_CLI_PATH} dispatch @agent [@agent…] \"instruction\"\``,
@@ -2892,6 +2922,22 @@ export function buildSystemPreamble(
2892
2922
  "`/workspace/.uai/chat.md`. Read it whenever you need that context;",
2893
2923
  "it's appended live, so re-read it for the latest.",
2894
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
+ ];
2895
2941
  const secretaryRoleBrief = isSecretary
2896
2942
  ? [
2897
2943
  "## Secretary role",
@@ -2920,7 +2966,14 @@ export function buildSystemPreamble(
2920
2966
  "instruction for each",
2921
2967
  "crew member you need. Do not merely say that someone else will answer.",
2922
2968
  ]
2923
- : [
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
+ : [
2924
2977
  "When a message already @-mentions several participants at once (the",
2925
2978
  "human asking the whole group, or a peer addressing multiple agents),",
2926
2979
  "it's a group broadcast — this is a GROUP CHAT and everyone named has",
@@ -2930,14 +2983,20 @@ export function buildSystemPreamble(
2930
2983
  "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
2931
2984
  "the message only wakes them again and spirals into duplicate replies.",
2932
2985
  "Say your piece and stop.",
2933
- ];
2986
+ ];
2934
2987
  const handoffBrief = isSecretary
2935
2988
  ? [
2936
2989
  "When crew work finishes, synthesize the outcome for the human and make",
2937
2990
  "the next decision or blocker explicit. Do not abandon an unresolved",
2938
2991
  "request silently, and do not wake a peer for acknowledgments alone.",
2939
2992
  ]
2940
- : [
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
+ : [
2941
3000
  "Hand off when you finish your part of the work. When you've made",
2942
3001
  "and committed your changes, or completed a review, end your reply by",
2943
3002
  "@-mentioning the agent who should act next and telling them what you",
@@ -2947,7 +3006,7 @@ export function buildSystemPreamble(
2947
3006
  "peer needs to act, it's fine to stop; only @-mention @you if you need",
2948
3007
  "their input or are handing back finished work for them to act on. Don't",
2949
3008
  "prolong an agent-to-agent exchange just to fill silence.",
2950
- ];
3009
+ ];
2951
3010
  const checkInTranscriptBrief = isSecretary
2952
3011
  ? [
2953
3012
  "Read both transcript files named above, and speak ONLY if you have",
@@ -2955,11 +3014,13 @@ export function buildSystemPreamble(
2955
3014
  "PASS reply is discarded and never shown to anyone, so it is always a",
2956
3015
  "safe way to decline a turn.",
2957
3016
  ]
2958
- : [
3017
+ : isSecretaryCrew
3018
+ ? []
3019
+ : [
2959
3020
  "Read the transcript, and speak ONLY if you have something substantive to",
2960
3021
  "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
2961
3022
  "never shown to anyone, so it is always a safe way to decline a turn.",
2962
- ];
3023
+ ];
2963
3024
  const workspaceBrief = isSecretary
2964
3025
  ? [
2965
3026
  "## Workspace layout",
@@ -3009,19 +3070,35 @@ export function buildSystemPreamble(
3009
3070
  "or @-mentioning a crew agent in prose does NOT wake them.",
3010
3071
  "The only way to hand crew work off is the structured `dispatch` action.",
3011
3072
  ]
3012
- : [
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
+ : [
3013
3083
  "You are one agent in a uai task chat channel, shared with the human",
3014
3084
  "and the other agents. To hand work to or ask another agent, mention",
3015
3085
  "it by id at the start of a line — e.g. `@codex please review the",
3016
3086
  "diff`. uai routes that message into that agent's input.",
3017
- ];
3087
+ ];
3018
3088
  const collaborationBrief = isSecretary
3019
3089
  ? [
3020
3090
  "Collaborate through deliberate dispatches. Each dispatch wakes a crew",
3021
3091
  "agent and costs a turn, so make every instruction concrete, self-contained,",
3022
3092
  "and necessary. Never dispatch greetings, thanks, or acknowledgments.",
3023
3093
  ]
3024
- : [
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
+ : [
3025
3102
  "An agent only receives a message when it is explicitly @-mentioned",
3026
3103
  "(or addressed by the human) — so always @-mention the agent (or @you)",
3027
3104
  "you mean. There is NO `peer` command and no shared tmux session;",
@@ -3040,7 +3117,7 @@ export function buildSystemPreamble(
3040
3117
  "don't @-mention back. And when there's no active task yet (intros, or",
3041
3118
  "you're waiting on the human), answer briefly and then wait — you don't",
3042
3119
  "need to @-mention anyone (including @you); they can see the channel.",
3043
- ];
3120
+ ];
3044
3121
  const channelConventionsBrief = isSecretary
3045
3122
  ? [
3046
3123
  "Your prose always goes to the human-facing lane; answer plainly without",
@@ -3049,13 +3126,19 @@ export function buildSystemPreamble(
3049
3126
  "You may occasionally receive a `[channel check-in]` asking you to catch",
3050
3127
  "up on the channel.",
3051
3128
  ]
3052
- : [
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
+ : [
3053
3136
  "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
3054
3137
  "uai hands it back to whoever prompted you — so when you're ANSWERING,",
3055
3138
  "just answer plainly; you don't need to re-mention the asker. Mention",
3056
3139
  "someone only to bring them in or hand work off. (2) You may occasionally",
3057
3140
  "receive a `[channel check-in]` asking you to catch up on the channel.",
3058
- ];
3141
+ ];
3059
3142
  const comms = [
3060
3143
  "## uai task channel",
3061
3144
  "",
@@ -3075,6 +3158,8 @@ export function buildSystemPreamble(
3075
3158
  "",
3076
3159
  ...transcriptBrief,
3077
3160
  "",
3161
+ ...sessionInboxBrief,
3162
+ ...(sessionInboxBrief.length > 0 ? [""] : []),
3078
3163
  ...secretaryRoleBrief,
3079
3164
  ...channelConventionsBrief,
3080
3165
  ...checkInTranscriptBrief,
@@ -3172,6 +3257,9 @@ export function buildSystemPreamble(
3172
3257
  (agent.permissions?.includes("tasks.create")
3173
3258
  ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
3174
3259
  : "") +
3260
+ (agent.permissions?.includes("tasks.close")
3261
+ ? ", `task close --status finished|canceled [--reason \"final report\"]`"
3262
+ : "") +
3175
3263
  (agent.permissions?.includes("memory.write")
3176
3264
  ? ", `memory save <text>`"
3177
3265
  : "") +
@@ -3208,6 +3296,17 @@ export function buildSystemPreamble(
3208
3296
  "`projects list` / `people list`.",
3209
3297
  ]
3210
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
+ : []),
3211
3310
  ...(agent.permissions?.includes("memory.read") ||
3212
3311
  agent.permissions?.includes("memory.write")
3213
3312
  ? [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.8",
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" };