@runuai/host 0.9.9 → 0.9.11

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.
@@ -9,28 +9,55 @@
9
9
  "workbench.welcomePage.walkthroughs.openOnInstall": false,
10
10
  "window.commandCenter": false,
11
11
  "chat.commandCenter.enabled": false,
12
+ "chat.viewSessions.enabled": false,
13
+ "chat.disableAIFeatures": true,
12
14
  "update.mode": "none",
13
15
  "extensions.autoCheckUpdates": false,
14
16
  "extensions.autoUpdate": false,
15
17
  "git.openRepositoryInParentFolders": "always",
16
- "workbench.activityBar.location": "top",
17
- "chat.viewSessions.enabled": false,
18
18
  "workbench.secondarySideBar.defaultVisibility": "hidden",
19
+
20
+ "//4": "Colour theme. Overwritten at FIRST init by uai-init when the task",
21
+ "//5": "carries UAI_EDITOR_THEME (ADR-091); this value is the fallback for",
22
+ "//6": "tasks created before that shipped, or created outside a browser.",
19
23
  "workbench.colorTheme": "Dark 2026",
20
24
 
21
- "//4": "Workspace Trust every task runs in an isolated container off the",
22
- "//5": "user's own repos. The 'do you trust this folder?' prompt is pure",
23
- "//6": "friction here; the user already trusts their code and the container",
24
- "//7": "is the security boundary.",
25
+ "//7": "An editor, not an IDE (ADR-092). This pane is one of three in a",
26
+ "//8": "task's right panel and is often half a screen wide, so every bar it",
27
+ "//9": "does not need is width the code does. Cut here is chrome only: the",
28
+ "//10": "status bar, the layout buttons, the menu bar, the empty-editor",
29
+ "//11": "watermark, Open Editors, the minimap and the terminal's chat hint.",
30
+ "//12": "Breadcrumbs STAY: with the status bar gone they are the only thing",
31
+ "//13": "naming the path, and a tab reading 'route.ts' is worth little in a",
32
+ "//14": "repo with eleven of them.",
33
+ "workbench.statusBar.visible": false,
34
+ "workbench.layoutControl.enabled": false,
35
+ "window.menuBarVisibility": "hidden",
36
+ "workbench.editor.empty.hint": "hidden",
37
+ "explorer.openEditors.visible": 0,
38
+ "editor.minimap.enabled": false,
39
+ "terminal.integrated.initialHint": false,
40
+
41
+ "//15": "Hide the activity bar and its custom title strip. Explorer stays",
42
+ "//16": "open; the Editor pane's own toolbar provides the pointer route to",
43
+ "//17": "Terminal. Individual pins still live in the BROWSER's IndexedDB; the",
44
+ "//18": "guarded migration keeps the requested containers ready if a user",
45
+ "//19": "later restores the bar.",
46
+ "workbench.activityBar.location": "hidden",
47
+
48
+ "//22": "Workspace Trust — every task runs in an isolated container off the",
49
+ "//23": "user's own repos. The 'do you trust this folder?' prompt is pure",
50
+ "//24": "friction here; the user already trusts their code and the container",
51
+ "//25": "is the security boundary.",
25
52
  "security.workspace.trust.enabled": false,
26
53
 
27
- "//8": "Port forwarding — code-server's own auto-forward / 'Open in Browser'",
28
- "//9": "popup can't reach anything useful here: dev servers are exposed",
29
- "//10": "through uai's preview tunnel (ADR-025), not code-server's forwarder.",
30
- "//11": "Disable it so the popup doesn't mislead.",
54
+ "//26": "Port forwarding — code-server's own auto-forward / 'Open in Browser'",
55
+ "//27": "popup can't reach anything useful here: dev servers are exposed",
56
+ "//28": "through uai's preview tunnel (ADR-025), not code-server's forwarder.",
57
+ "//29": "Disable it so the popup doesn't mislead.",
31
58
  "remote.autoForwardPorts": false,
32
59
  "remote.restoreForwardedPorts": false,
33
60
 
34
- "//12": "Integrated terminal uses zsh + oh-my-zsh (the node login shell).",
61
+ "//30": "Integrated terminal uses zsh + oh-my-zsh (the node login shell).",
35
62
  "terminal.integrated.defaultProfile.linux": "zsh"
36
63
  }
@@ -219,11 +219,41 @@ cs_source_seed="$(dirname "${BASH_SOURCE[0]}")/code-server-settings.json"
219
219
  if [ ! -f "$cs_seed" ] && [ -f "$cs_source_seed" ]; then
220
220
  cs_seed="$cs_source_seed"
221
221
  fi
222
+
223
+ # ADR-091: seed the Editor pane in the palette the task was created from.
224
+ # UAI_EDITOR_THEME carries a VS Code theme LABEL ("Quiet Light"), already
225
+ # resolved cloud-side from the theme registry — the container never maps a Uai
226
+ # palette id and never sees a raw user string. Unset is the normal state for a
227
+ # task created before this shipped or outside a browser; the seed's own baked
228
+ # default then stands.
229
+ #
230
+ # Applied ONLY while seeding. An existing settings.json is still left entirely
231
+ # alone, so a user's own editor tweaks survive a restart — which is also why
232
+ # changing the app's palette later does not re-theme an already-created task
233
+ # (docs/theming.md). If jq is missing or the filter fails, fall through to the
234
+ # plain copy: a themed editor is worth less than an editor that starts.
235
+ seed_code_server_settings() {
236
+ local seed="$1" dest="$2" theme="${UAI_EDITOR_THEME:-}"
237
+ if [ -n "$theme" ] && command -v jq >/dev/null 2>&1; then
238
+ if jq --arg theme "$theme" '.["workbench.colorTheme"] = $theme' \
239
+ "$seed" >"$dest.tmp" 2>/dev/null && [ -s "$dest.tmp" ]; then
240
+ mv "$dest.tmp" "$dest"
241
+ log "seeded code-server settings (theme: $theme)"
242
+ return 0
243
+ fi
244
+ rm -f "$dest.tmp"
245
+ log "warning: could not apply UAI_EDITOR_THEME=$theme; using image default"
246
+ fi
247
+ cp "$seed" "$dest" || return 1
248
+ log "seeded code-server settings"
249
+ }
250
+
222
251
  if [ ! -f "$cs_user_dir/settings.json" ]; then
223
252
  if [ ! -f "$cs_seed" ]; then
224
253
  log "warning: code-server settings seed missing at $cs_seed"
225
- elif mkdir -p "$cs_user_dir" && cp "$cs_seed" "$cs_user_dir/settings.json"; then
226
- log "seeded code-server settings"
254
+ elif mkdir -p "$cs_user_dir" &&
255
+ seed_code_server_settings "$cs_seed" "$cs_user_dir/settings.json"; then
256
+ :
227
257
  else
228
258
  log "warning: could not seed code-server settings at $cs_user_dir/settings.json"
229
259
  fi
@@ -238,6 +268,7 @@ else
238
268
  --disable-workspace-trust \
239
269
  --auth none \
240
270
  --disable-telemetry \
271
+ --disable-update-check \
241
272
  --bind-addr 0.0.0.0:8080 \
242
273
  "$editor_root" \
243
274
  >/tmp/code-server.log 2>&1 &
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.11",
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.
@@ -1081,6 +1081,13 @@ fi
1081
1081
  # Projects may declare managed names, so skip them here rather than trust
1082
1082
  # callers. They are also removed from the in-memory Compose override below.
1083
1083
  #
1084
+ # UAI_EDITOR_THEME is reserved in BOTH places for the case that is easy to
1085
+ # miss: a task whose stored palette is null, on a project that happens to
1086
+ # declare that name. The cloud sends no theme, so the `preview_env` entry
1087
+ # that normally shadows a declared key is absent too — and without these two
1088
+ # lines a project would get to pick what the Editor pane looks like. Reserved,
1089
+ # the name is not emitted at all and uai-init seeds the image's own default.
1090
+ #
1084
1091
  # Project values never enter the host environment. Emit an explicit empty
1085
1092
  # default for every declared key; the stdin-only Compose override supplies
1086
1093
  # decrypted values at `compose up` time.
@@ -1094,15 +1101,23 @@ fi
1094
1101
  GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
1095
1102
  BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
1096
1103
  CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
1097
- ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH) continue ;;
1104
+ ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH | \
1105
+ UAI_EDITOR_THEME) continue ;;
1098
1106
  esac
1099
1107
  printf ' %s: ""\n' "$ekey"
1100
1108
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
1101
- # Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
1102
- # under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
1103
- # written LITERALLY (unlike the ${KEY:-} pass-throughs above). `preview_env`
1104
- # is a JSON object {VAR: url} on the task row, one entry per preview port that
1105
- # set `urlEnv`. Emitted last so it wins over any same-named declared key.
1109
+ # Cloud-computed literal env (ADR-025): a JSON object {VAR: value} on the
1110
+ # task row. Non-secret, so written LITERALLY (unlike the ${KEY:-}
1111
+ # pass-throughs above). Emitted last so it wins over any same-named declared
1112
+ # key. Two producers, both cloud-side and both opaque here:
1113
+ # - PUBLIC preview URLs under operator-chosen names (EXPO_PACKAGER_PROXY_URL
1114
+ # and friends), one per preview port that set `urlEnv`;
1115
+ # - UAI_EDITOR_THEME (ADR-091), the Editor pane's colour theme as a VS Code
1116
+ # theme LABEL ("Quiet Light"), already resolved from the palette the
1117
+ # creating browser snapshotted. The host never maps a Uai palette id.
1118
+ # UAI_EDITOR_THEME is absent for every task created before it shipped and for
1119
+ # any created outside a browser; uai-init then seeds the image's own default,
1120
+ # so the column staying null is a supported state, not a value to fill in.
1106
1121
  preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
1107
1122
  while IFS= read -r pe_entry; do
1108
1123
  [ -n "$pe_entry" ] || continue
@@ -1188,7 +1203,8 @@ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1188
1203
  .ANTHROPIC_API_KEY,
1189
1204
  .ANTHROPIC_AUTH_TOKEN,
1190
1205
  .XAI_API_KEY,
1191
- .PLAYWRIGHT_BROWSERS_PATH
1206
+ .PLAYWRIGHT_BROWSERS_PATH,
1207
+ .UAI_EDITOR_THEME
1192
1208
  )
1193
1209
  | delpaths($preview | keys | map([.]))
1194
1210
  | {services:{app:{environment:.}}}
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" };
@@ -191,11 +199,17 @@ export interface TaskCommandTask {
191
199
  reviewerOrder?: string[];
192
200
  agents: TaskAgent[];
193
201
  /**
194
- * Cloud-computed PUBLIC preview URLs to inject into the container env at
195
- * task-up, keyed by the operator-chosen var name (e.g.
196
- * `EXPO_PACKAGER_PROXY_URL`). Non-secret written literally into the task's
197
- * compose file. Only present on task-up commands, when a preview port set
198
- * `urlEnv` and the cloud has a preview base domain configured.
202
+ * Cloud-computed literal env to inject into the container at task-up.
203
+ * Non-secret by construction — written straight into the task's compose file,
204
+ * unlike the `${KEY:-}` pass-throughs used for credentials. Only present on
205
+ * task-up commands.
206
+ *
207
+ * Named for its first user, PUBLIC preview URLs keyed by the operator-chosen
208
+ * var name (e.g. `EXPO_PACKAGER_PROXY_URL`, present when a preview port set
209
+ * `urlEnv` and the cloud has a preview base domain). It now also carries
210
+ * `UAI_EDITOR_THEME` (ADR-091) — same shape, same guarantees, same one-way
211
+ * trip to `services.app.environment`, so it rides here rather than growing
212
+ * the protocol a second env channel. The host treats every entry as opaque.
199
213
  */
200
214
  previewEnv?: Record<string, string>;
201
215
  /** ADR-062: owning org — locates the org shared-files root on the host. */