@runuai/host 0.2.6 → 0.3.0

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.
@@ -0,0 +1,7 @@
1
+ -- ADR-033: non-expiring per-host tokens. `kind` distinguishes the stored token:
2
+ -- 'refresh' (legacy, ADR-027) → refresh_token_ct holds a refresh token; the
3
+ -- host exchanges it for short-lived access tokens.
4
+ -- 'access' (ADR-033) → refresh_token_ct holds a long-lived, non-
5
+ -- expiring access token; injected directly, no exchange/refresh.
6
+ -- Existing rows default to 'refresh' (their current semantics).
7
+ ALTER TABLE `host_github_tokens` ADD `kind` text DEFAULT 'refresh' NOT NULL;
@@ -50,6 +50,13 @@
50
50
  "when": 1779900007000,
51
51
  "tag": "0006_host_project_env",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1779900008000,
58
+ "tag": "0007_host_github_token_kind",
59
+ "breakpoints": true
53
60
  }
54
61
  ]
55
62
  }
package/db/schema.ts CHANGED
@@ -62,9 +62,13 @@ export type NewHostEventRow = typeof hostEvents.$inferInsert;
62
62
  export const githubTokens = sqliteTable("host_github_tokens", {
63
63
  userId: text("user_id").primaryKey(),
64
64
  installationId: integer("installation_id").notNull(),
65
+ // ADR-033: holds a long-lived ACCESS token when kind="access", or a refresh
66
+ // token when kind="refresh" (legacy ADR-027). Encrypted at rest either way.
65
67
  refreshTokenCt: blob("refresh_token_ct").notNull(),
66
68
  refreshTokenNonce: blob("refresh_token_nonce").notNull(),
67
69
  refreshTokenExpiresAt: integer("refresh_token_expires_at"),
70
+ // "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
71
+ kind: text("kind").notNull().default("refresh"),
68
72
  updatedAt: integer("updated_at").notNull(),
69
73
  });
70
74
 
@@ -338,6 +338,14 @@ register({
338
338
  defaultModel: CLAUDE_DEFAULT_MODEL,
339
339
  supportedEfforts: () => [...CLAUDE_EFFORTS],
340
340
  defaultEffort: CLAUDE_DEFAULT_EFFORT,
341
+ // Usable only when a Claude credential is in the env (injected into task
342
+ // containers at task-up). Gates advertisement (ADR-044 P2).
343
+ available: () =>
344
+ Boolean(
345
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ||
346
+ process.env.ANTHROPIC_API_KEY ||
347
+ process.env.ANTHROPIC_AUTH_TOKEN,
348
+ ),
341
349
  create: async ({ agent, containerName, systemPreamble }) =>
342
350
  new ClaudeSession({ agent, containerName, systemPreamble }),
343
351
  });
@@ -28,6 +28,10 @@
28
28
  * namespaces inside it anyway.
29
29
  */
30
30
 
31
+ import { existsSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+
31
35
  import { newId } from "../ulid";
32
36
  import { dockerExecArgs, LineProcess } from "./proc";
33
37
  import { register } from "./registry";
@@ -517,6 +521,13 @@ register({
517
521
  defaultModel: CODEX_DEFAULT_MODEL,
518
522
  supportedEfforts: () => [...CODEX_EFFORTS],
519
523
  defaultEffort: CODEX_DEFAULT_EFFORT,
524
+ // Usable only when the owner's ~/.codex login exists — task-up copies it into
525
+ // each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
526
+ // (ADR-044 P2).
527
+ available: () =>
528
+ existsSync(
529
+ join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
530
+ ),
520
531
  create: async ({ agent, containerName, systemPreamble }) =>
521
532
  new CodexSession({ agent, containerName, systemPreamble }),
522
533
  });
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
32
32
  supportedEfforts(): string[];
33
33
  /** Preferred effort when the user doesn't pick one. */
34
34
  defaultEffort?: string;
35
+ /**
36
+ * Whether this kind is usable on THIS host right now — i.e. its credentials
37
+ * are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
38
+ * out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
39
+ * host can't actually run. Omit (or return true) to always advertise; the
40
+ * session factory stays permissive regardless.
41
+ */
42
+ available?: () => boolean;
35
43
  /** Builds an AgentSession for a roster entry of this kind. */
36
44
  create: AgentSessionFactory["create"];
37
45
  }
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
76
84
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
77
85
  */
78
86
  export function capabilities(): AgentKindCapability[] {
79
- return list().map((adapter) => {
87
+ return list()
88
+ .filter((adapter) => adapter.available?.() ?? true)
89
+ .map((adapter) => {
80
90
  const out: AgentKindCapability = {
81
91
  kind: adapter.kind,
82
92
  label: adapter.label,
package/lib/command-db.ts CHANGED
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
135
135
  id, owner_user_id, host_id, name, slug, branch, status,
136
136
  global_context, reviewer_order, agents, pr_context,
137
137
  worktree_path, compose_project, code_server_port, preview_ports,
138
- locked_at, started_at, ended_at
138
+ preview_env, locked_at, started_at, ended_at
139
139
  ) VALUES (
140
140
  @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
141
141
  @global_context, @reviewer_order, @agents, @pr_context,
142
142
  @worktree_path, @compose_project, @code_server_port, @preview_ports,
143
- @locked_at, @started_at, @ended_at
143
+ @preview_env, @locked_at, @started_at, @ended_at
144
144
  )`,
145
145
  ).run(task);
146
146
  }
@@ -166,6 +166,7 @@ interface CommandTaskRow {
166
166
  compose_project: string | null;
167
167
  code_server_port: number | null;
168
168
  preview_ports: string;
169
+ preview_env: string | null;
169
170
  locked_at: number | null;
170
171
  started_at: number | null;
171
172
  ended_at: number | null;
@@ -193,6 +194,7 @@ function withRuntime(
193
194
  compose_project: runtime?.composeProject ?? null,
194
195
  code_server_port: runtime?.codeServerPort ?? null,
195
196
  preview_ports: runtime?.previewPorts ?? "[]",
197
+ preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
196
198
  locked_at: runtime?.lockedAt ?? null,
197
199
  started_at: runtime?.startedAt ?? null,
198
200
  ended_at: runtime?.endedAt ?? null,
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
216
218
  compose_project: runtime?.composeProject ?? null,
217
219
  code_server_port: runtime?.codeServerPort ?? null,
218
220
  preview_ports: runtime?.previewPorts ?? "[]",
221
+ preview_env: null,
219
222
  locked_at: runtime?.lockedAt ?? null,
220
223
  started_at: runtime?.startedAt ?? null,
221
224
  ended_at: runtime?.endedAt ?? null,
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
262
265
  compose_project text,
263
266
  code_server_port integer,
264
267
  preview_ports text NOT NULL DEFAULT '[]',
268
+ preview_env text,
265
269
  pr_url text,
266
270
  locked_at integer,
267
271
  started_at integer,
package/lib/git-diff.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Parse `git diff` unified output into structured per-file patches.
3
3
  *
4
- * Input is whatever `git diff origin/<base>..HEAD` produces multi-file,
5
- * with headers, mode/rename/binary annotations, and `@@` hunks. The
6
- * output is a list of `DiffFile`s the UI can render per-file with
4
+ * Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
5
+ * appended `--no-index` new-file patches for untracked files produce —
6
+ * multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
7
+ * The output is a list of `DiffFile`s the UI can render per-file with
7
8
  * collapse/expand and syntax-highlighted hunks.
8
9
  *
9
10
  * Hand-rolled rather than pulled in as a dep — the unified-diff grammar
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
98
99
  containerName: string,
99
100
  cwd: string,
100
101
  range: string,
102
+ ): Promise<string> {
103
+ return runDiffCommand(
104
+ "docker",
105
+ [...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
106
+ "docker exec git diff",
107
+ process.env,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Render a single untracked file as a new-file patch by diffing it against
113
+ * `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
114
+ * entirely, so this is how they reach the "all changes vs base" view.
115
+ *
116
+ * `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
117
+ * case for a new file) and 0 when identical — both are success here; only
118
+ * code > 1 is a real error.
119
+ */
120
+ export function runGitDiffUntracked(
121
+ cwd: string,
122
+ relPath: string,
123
+ ): Promise<string> {
124
+ return runDiffCommand(
125
+ "git",
126
+ ["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
127
+ "git diff --no-index",
128
+ { ...process.env, ...NON_INTERACTIVE_GIT_ENV },
129
+ true,
130
+ );
131
+ }
132
+
133
+ /** In-container variant of {@link runGitDiffUntracked}. */
134
+ export function runGitDiffUntrackedInContainer(
135
+ containerName: string,
136
+ cwd: string,
137
+ relPath: string,
101
138
  ): Promise<string> {
102
139
  return runDiffCommand(
103
140
  "docker",
104
141
  [
105
- "exec",
106
- "-e",
107
- `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
108
- "-e",
109
- `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
110
- "-e",
111
- `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
112
- "-w",
113
- cwd,
114
- containerName,
142
+ ...dockerExecPrefix(containerName, cwd),
115
143
  "git",
116
144
  ...GIT_DIFF_ARGS,
117
- range,
145
+ "--no-index",
146
+ "--",
147
+ "/dev/null",
148
+ relPath,
118
149
  ],
119
- "docker exec git diff",
150
+ "docker exec git diff --no-index",
120
151
  process.env,
152
+ true,
121
153
  );
122
154
  }
123
155
 
156
+ /** `docker exec` argv prefix with the non-interactive git env + working dir. */
157
+ function dockerExecPrefix(containerName: string, cwd: string): string[] {
158
+ return [
159
+ "exec",
160
+ "-e",
161
+ `GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
162
+ "-e",
163
+ `GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
164
+ "-e",
165
+ `GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
166
+ "-w",
167
+ cwd,
168
+ containerName,
169
+ ];
170
+ }
171
+
124
172
  function runDiffCommand(
125
173
  command: string,
126
174
  args: string[],
127
175
  label: string,
128
176
  env: NodeJS.ProcessEnv,
177
+ /** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
178
+ * compared inputs differ, which for a new-file patch is the expected case. */
179
+ allowExitOne = false,
129
180
  ): Promise<string> {
130
181
  return new Promise((res, rej) => {
131
182
  // Force ASCII paths and pull a generous unified context so the UI
@@ -144,7 +195,7 @@ function runDiffCommand(
144
195
  });
145
196
  child.on("error", rej);
146
197
  child.on("close", (code) => {
147
- if (code === 0) {
198
+ if (code === 0 || (allowExitOne && code === 1)) {
148
199
  res(stdout);
149
200
  return;
150
201
  }
@@ -26,56 +26,101 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
26
26
 
27
27
  // --- token store ------------------------------------------------------------
28
28
 
29
- /** Encrypt + persist the user's refresh token (gh.connect.set handler). */
29
+ /**
30
+ * Encrypt + persist the user's token (gh.connect.set handler). ADR-033: a
31
+ * non-expiring grant carries `accessToken` (stored kind="access", injected
32
+ * directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
33
+ * exchanged per task). Exactly one is present.
34
+ */
30
35
  export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
31
36
  try {
32
37
  // Fresh grant — a cached access token from the previous grant may be
33
38
  // revoked; drop it so the next mint exchanges against the new token.
34
39
  clearAccessCache(frame.userId);
35
- const sealed = sealAesGcm(frame.refreshToken);
40
+ const token = frame.accessToken ?? frame.refreshToken;
41
+ if (!token) return { ok: false, error: "gh.connect.set carried no token" };
42
+ const kind = frame.accessToken ? "access" : "refresh";
43
+ const sealed = sealAesGcm(token);
36
44
  const now = Date.now();
45
+ const fields = {
46
+ installationId: frame.installationId,
47
+ refreshTokenCt: sealed.ct,
48
+ refreshTokenNonce: sealed.nonce,
49
+ refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
50
+ kind,
51
+ updatedAt: now,
52
+ };
37
53
  getDb()
38
54
  .insert(schema.githubTokens)
39
- .values({
40
- userId: frame.userId,
41
- installationId: frame.installationId,
42
- refreshTokenCt: sealed.ct,
43
- refreshTokenNonce: sealed.nonce,
44
- refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
45
- updatedAt: now,
46
- })
47
- .onConflictDoUpdate({
48
- target: schema.githubTokens.userId,
49
- set: {
50
- installationId: frame.installationId,
51
- refreshTokenCt: sealed.ct,
52
- refreshTokenNonce: sealed.nonce,
53
- refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
54
- updatedAt: now,
55
- },
56
- })
55
+ .values({ userId: frame.userId, ...fields })
56
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
57
57
  .run();
58
+ notifyGithubChange();
58
59
  return { ok: true };
59
60
  } catch (err) {
60
61
  return { ok: false, error: err instanceof Error ? err.message : "store failed" };
61
62
  }
62
63
  }
63
64
 
64
- /** Delete a user's refresh token + clear their active-task refresh timers. */
65
- export function onConnectClear(userId: string): void {
66
- deleteToken(userId);
67
- for (const taskId of activeTaskIdsForUser(userId)) {
68
- clearRefresh(taskId);
69
- authExpiredHandler?.(taskId, userId, "GitHub disconnected");
65
+ /**
66
+ * Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
67
+ * locally FIRST (so capabilities re-advertise immediately and the UI reflects
68
+ * removal without waiting on the network), then best-effort revokes it at
69
+ * GitHub. Whole body is guarded — a fired-and-forgotten failure must never
70
+ * crash the host (no global unhandledRejection handler).
71
+ *
72
+ * ADR-033: only a non-expiring ACCESS token can be revoked by token
73
+ * (`DELETE /applications/{client_id}/token` matches access tokens only — a
74
+ * refresh token 404s, which the cloud endpoint would mis-report as success).
75
+ * Legacy refresh tokens aren't single-token revocable; the short-lived access
76
+ * tokens they mint expire on their own.
77
+ */
78
+ export async function onConnectClear(userId: string): Promise<void> {
79
+ try {
80
+ const stored = readStoredToken(userId);
81
+ deleteToken(userId); // fires onGithubChange → re-advertise capabilities
82
+ for (const taskId of activeTaskIdsForUser(userId)) {
83
+ clearRefresh(taskId);
84
+ authExpiredHandler?.(taskId, userId, "GitHub disconnected");
85
+ }
86
+ if (stored && stored.kind === "access") {
87
+ await revokeAtGitHub(stored.token);
88
+ } else if (stored) {
89
+ console.warn(
90
+ `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
91
+ );
92
+ }
93
+ } catch (err) {
94
+ console.warn(
95
+ `[github] disconnect failed: ${err instanceof Error ? err.message : err}`,
96
+ );
70
97
  }
71
98
  }
72
99
 
100
+ /** Decrypt the user's stored token + its kind, or null if none. */
101
+ function readStoredToken(
102
+ userId: string,
103
+ ): { token: string; kind: string } | null {
104
+ const row = getDb()
105
+ .select()
106
+ .from(schema.githubTokens)
107
+ .where(eq(schema.githubTokens.userId, userId))
108
+ .get();
109
+ if (!row) return null;
110
+ const token = openAesGcm(
111
+ Buffer.from(row.refreshTokenCt as Uint8Array),
112
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
113
+ );
114
+ return { token, kind: row.kind };
115
+ }
116
+
73
117
  export function deleteToken(userId: string): void {
74
118
  clearAccessCache(userId);
75
119
  getDb()
76
120
  .delete(schema.githubTokens)
77
121
  .where(eq(schema.githubTokens.userId, userId))
78
122
  .run();
123
+ notifyGithubChange();
79
124
  }
80
125
 
81
126
  export function hasToken(userId: string): boolean {
@@ -88,6 +133,29 @@ export function hasToken(userId: string): boolean {
88
133
  );
89
134
  }
90
135
 
136
+ /** All cloud user ids with a GitHub token on this host (for capabilities). */
137
+ export function connectedUserIds(): string[] {
138
+ return getDb()
139
+ .select({ userId: schema.githubTokens.userId })
140
+ .from(schema.githubTokens)
141
+ .all()
142
+ .map((r) => r.userId);
143
+ }
144
+
145
+ // Fires whenever the set of stored tokens changes (add/remove), so the host
146
+ // can re-advertise capabilities and the UI reflects per-host gh state promptly.
147
+ let githubChangeHandler: (() => void) | null = null;
148
+ export function onGithubChange(fn: (() => void) | null): void {
149
+ githubChangeHandler = fn;
150
+ }
151
+ function notifyGithubChange(): void {
152
+ try {
153
+ githubChangeHandler?.();
154
+ } catch {
155
+ /* never let a listener break token handling */
156
+ }
157
+ }
158
+
91
159
  function activeTaskIdsForUser(userId: string): string[] {
92
160
  return getDb()
93
161
  .select({ taskId: schema.hostTasks.taskId })
@@ -104,12 +172,43 @@ function activeTaskIdsForUser(userId: string): string[] {
104
172
 
105
173
  // --- access-token exchange --------------------------------------------------
106
174
 
107
- function exchangeUrl(): string {
175
+ function cloudHttpBase(): string {
108
176
  const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
109
177
  const u = new URL(cloud);
110
178
  const proto =
111
179
  u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
112
- return `${proto}//${u.host}/api/github/oauth/exchange`;
180
+ return `${proto}//${u.host}`;
181
+ }
182
+
183
+ function exchangeUrl(): string {
184
+ return `${cloudHttpBase()}/api/github/oauth/exchange`;
185
+ }
186
+
187
+ /**
188
+ * Revoke a token at GitHub via the cloud (which holds the App client secret —
189
+ * the host can't revoke alone). Same host→cloud HTTP pattern as the exchange;
190
+ * the token transits the cloud transiently, never stored (ADR-015/ADR-033).
191
+ * Best-effort — callers proceed to delete locally regardless.
192
+ */
193
+ async function revokeAtGitHub(token: string): Promise<void> {
194
+ try {
195
+ const res = await fetch(`${cloudHttpBase()}/api/github/revoke`, {
196
+ method: "POST",
197
+ headers: {
198
+ "content-type": "application/json",
199
+ "x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
200
+ },
201
+ body: JSON.stringify({ token }),
202
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
203
+ });
204
+ if (!res.ok) {
205
+ console.warn(`[github] token revoke returned HTTP ${res.status}`);
206
+ }
207
+ } catch (err) {
208
+ console.warn(
209
+ `[github] token revoke failed (deleting locally anyway): ${err instanceof Error ? err.message : err}`,
210
+ );
211
+ }
113
212
  }
114
213
 
115
214
  interface ExchangeResult {
@@ -165,7 +264,23 @@ export function clearAllAccessCache(): void {
165
264
 
166
265
  export function requestAccessToken(
167
266
  userId: string,
168
- ): Promise<{ accessToken: string; expiresAt: number } | null> {
267
+ ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
268
+ // ADR-033 non-expiring path: the stored token IS the access token — return it
269
+ // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
270
+ const row = getDb()
271
+ .select({ kind: schema.githubTokens.kind })
272
+ .from(schema.githubTokens)
273
+ .where(eq(schema.githubTokens.userId, userId))
274
+ .get();
275
+ if (!row) return Promise.resolve(null);
276
+ if (row.kind === "access") {
277
+ const stored = readStoredToken(userId);
278
+ return Promise.resolve(
279
+ stored ? { accessToken: stored.token, expiresAt: null } : null,
280
+ );
281
+ }
282
+
283
+ // Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
169
284
  const cached = accessCache.get(userId);
170
285
  if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
171
286
  return Promise.resolve(cached);
@@ -327,7 +442,8 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
327
442
  return;
328
443
  }
329
444
  await injectIntoContainer(taskId, tok.accessToken);
330
- scheduleRefresh(taskId, userId, tok.expiresAt);
445
+ // Only re-schedule for an expiring token; non-expiring needs no refresh.
446
+ if (tok.expiresAt !== null) scheduleRefresh(taskId, userId, tok.expiresAt);
331
447
  } catch (err) {
332
448
  const reason = err instanceof Error ? err.message : String(err);
333
449
  // A revoked / expired refresh token can't recover — drop it to force a
@@ -423,7 +539,7 @@ export interface SetupTaskDeps {
423
539
  hasToken?: (userId: string) => boolean;
424
540
  requestAccessToken?: (
425
541
  userId: string,
426
- ) => Promise<{ accessToken: string; expiresAt: number } | null>;
542
+ ) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
427
543
  inject?: (taskId: string, token: string) => void | Promise<void>;
428
544
  schedule?: (taskId: string, userId: string, expiresAt: number) => void;
429
545
  deleteToken?: (userId: string) => void;
@@ -460,7 +576,8 @@ export async function setupTaskGithub(
460
576
  const tok = await _request(userId);
461
577
  if (tok) {
462
578
  await _inject(taskId, tok.accessToken);
463
- _schedule(taskId, userId, tok.expiresAt);
579
+ // Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
580
+ if (tok.expiresAt !== null) _schedule(taskId, userId, tok.expiresAt);
464
581
  clearGithubRetry(taskId);
465
582
  console.log(`[github] task ${taskId}: injected user access token`);
466
583
  return true;
@@ -235,14 +235,13 @@ class Orchestrator {
235
235
  });
236
236
  }
237
237
 
238
- // Container is ready: deliver each agent's first-turn prompt (ADR-022).
239
- // Only agents whose `initialPrompt` was non-empty have an entry.
240
- for (const agent of channel.roster) {
241
- const firstTurn = channel.firstTurns.get(agent.id);
242
- if (firstTurn === undefined) continue;
243
- const session = channel.sessions.get(agent.id);
244
- if (session) void session.send(firstTurn);
245
- }
238
+ // First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
239
+ // which fires each agent's initialPrompt exactly once per task — guarded by
240
+ // a non-empty message history (its durable "already started" check). The
241
+ // host must NOT deliver it here: this path re-runs on every session spawn,
242
+ // so it both duplicated the opening turn AND re-injected it on a host
243
+ // restart, making agents redo the first turn mid-task. The persona/mission
244
+ // an agent needs live in the always-on system preamble, so nothing is lost.
246
245
  return true;
247
246
  }
248
247
 
@@ -723,7 +722,11 @@ export function buildSystemPreamble(
723
722
  workspacePath: string,
724
723
  taskBranch: string,
725
724
  ): string {
726
- const others = roster.map((a) => `@${a.id} (${a.label})`).join(", ");
725
+ const channelList = roster
726
+ .map((a) =>
727
+ a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
728
+ )
729
+ .join(", ");
727
730
  const projectLines =
728
731
  projects.length === 0
729
732
  ? ["(none mounted)"]
@@ -739,12 +742,50 @@ export function buildSystemPreamble(
739
742
  "it by id at the start of a line — e.g. `@codex please review the",
740
743
  "diff`. uai routes that message into that agent's input.",
741
744
  "",
742
- `Agents in this channel: ${others}.`,
745
+ `**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
746
+ `Introduce and refer to yourself as ${agent.label}, not as a generic`,
747
+ "assistant or model name.",
748
+ "",
749
+ `Agents in this channel: ${channelList}.`,
750
+ "",
751
+ "The human you're working with is **@you**. @-mentioning them sends a",
752
+ "NOTIFICATION, so use it sparingly — only when you actually need them: a",
753
+ "decision you can't make, a blocker, an approval, or you've finished your",
754
+ "work and are handing it back for them to act on (e.g. `@you which`",
755
+ "`approach do you prefer?` or `@you done — PR is up for review`). For",
756
+ "everything else — status updates, thinking out loud, a direct reply to",
757
+ "something they just asked, acknowledgments — post in the channel WITHOUT",
758
+ "@-mentioning @you; they can read the channel and don't need a ping for",
759
+ "every message. Do NOT reflexively end messages with @you.",
743
760
  "",
744
761
  "An agent only receives a message when it is explicitly @-mentioned",
745
- "(or addressed by the human) — so always @-mention the agent you mean.",
746
- "There is NO `peer` command and no shared tmux session; hand-offs are",
747
- "just @-mentions in your replies.",
762
+ "(or addressed by the human) — so always @-mention the agent (or @you)",
763
+ "you mean. There is NO `peer` command and no shared tmux session;",
764
+ "hand-offs are just @-mentions in your replies.",
765
+ "",
766
+ "Collaborate with your peers — divide up the work, review each other's",
767
+ "changes, share concrete ideas, and debate approach decisions by",
768
+ "@-mentioning them. That is how the team gets things done, and you",
769
+ "should do it freely whenever it moves the work forward. But mentioning",
770
+ "a peer WAKES it and costs a turn, so make each one count: a message to",
771
+ "a peer should ADVANCE the work — a real proposal, a question you need",
772
+ "answered, a hand-off, or a review with specific findings. Do NOT",
773
+ "@-mention a peer just to greet, thank, agree, acknowledge, or say",
774
+ "you're ready — content-free replies wake them for nothing and spiral",
775
+ "into endless back-and-forth. If you have nothing substantive to add,",
776
+ "don't @-mention back. And when there's no active task yet (intros, or",
777
+ "you're waiting on the human), answer briefly and then wait — you don't",
778
+ "need to @-mention anyone (including @you); they can see the channel.",
779
+ "",
780
+ "When a message already @-mentions several participants at once (the",
781
+ "human asking the whole group, or a peer addressing multiple agents),",
782
+ "it's a group broadcast — this is a GROUP CHAT and everyone named has",
783
+ "ALREADY been notified and will answer for themselves. Just answer for",
784
+ "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
785
+ "question to them, or wait on them — no `I'll let @x speak`, `@x your",
786
+ "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
787
+ "the message only wakes them again and spirals into duplicate replies.",
788
+ "Say your piece and stop.",
748
789
  "",
749
790
  "Because your input is only what you're addressed, you may be missing",
750
791
  "context from messages between the human and the other agents. The full",
@@ -757,9 +798,11 @@ export function buildSystemPreamble(
757
798
  "and committed your changes, or completed a review, end your reply by",
758
799
  "@-mentioning the agent who should act next and telling them what you",
759
800
  "did and what you need (e.g. `@codex changes committed on <branch> —",
760
- "please review`, or `@claude review done, N issues to fix`). Never",
761
- "stop silently — if genuinely no agent needs to act, say so to the",
762
- "human.",
801
+ "please review`, or `@claude review done, N issues to fix`). Don't",
802
+ "abandon unfinished work silently — but once your part is done and no",
803
+ "peer needs to act, it's fine to stop; only @-mention @you if you need",
804
+ "their input or are handing back finished work for them to act on. Don't",
805
+ "prolong an agent-to-agent exchange just to fill silence.",
763
806
  "",
764
807
  "## Workspace layout",
765
808
  "",