@runuai/host 0.9.102 → 0.9.104

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,3 @@
1
+ -- Manage-v1 (host 0.9.104): explicit priority order for a kind's extra
2
+ -- accounts within a scope. Existing rows keep 0 and fall back to created_at.
3
+ ALTER TABLE `host_engine_accounts` ADD `position` integer NOT NULL DEFAULT 0;
@@ -127,6 +127,13 @@
127
127
  "when": 1788820000000,
128
128
  "tag": "0017_engine_account_scope",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "6",
134
+ "when": 1788900000000,
135
+ "tag": "0018_engine_account_position",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
139
  }
package/db/schema.ts CHANGED
@@ -278,6 +278,9 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
278
278
  // ADR-131 D3: "host" (owner-level, default) or "org:<orgId>" (a workspace's
279
279
  // account on a shared host). Resolution is by exact scope.
280
280
  scope: text("scope").notNull().default("host"),
281
+ // Manage-v1: priority within (scope, kind) — tasks pick the first healthy
282
+ // account in ascending position; ties break on created_at.
283
+ position: integer("position", { mode: "number" }).notNull().default(0),
281
284
  createdAt: integer("created_at", { mode: "number" }).notNull(),
282
285
  });
283
286
 
@@ -25,6 +25,7 @@
25
25
  import {
26
26
  HOST_ACCOUNT_SCOPE,
27
27
  isEngineAccountScope,
28
+ isEngineAccountLabel,
28
29
  } from "../src/protocol";
29
30
  import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
30
31
  import { homedir } from "node:os";
@@ -280,7 +281,8 @@ function resolveExtras(
280
281
  (r) =>
281
282
  r.kind === kind &&
282
283
  accountInScope((r as { scope?: string | null }).scope, scope),
283
- );
284
+ )
285
+ .sort(compareAccountRows);
284
286
  const out: ResolvedEngineAccount[] = [];
285
287
  for (const row of rows) {
286
288
  if (row.authKind === "env") {
@@ -440,8 +442,16 @@ export function selectAccount(
440
442
  if (candidates.length === 0) return null;
441
443
  const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
442
444
  const healthy = candidates.filter((a) => state(a.id).cooldownUntil <= now);
443
- const pool = healthy.length > 0 ? healthy : candidates;
444
- return pool.reduce((best, a) => (lru(a) < lru(best) ? a : best), pool[0]!);
445
+ // Manage-v1: the list arrives in PRIORITY order (default slot, then extras
446
+ // by position) — the first healthy account wins, so the operator's
447
+ // ordering decides which account a task uses and which are fallbacks.
448
+ // Only when every account is cooling do we fall back to the least recently
449
+ // used one (the stalest cooldown is the likeliest to have lifted).
450
+ if (healthy.length > 0) return healthy[0]!;
451
+ return candidates.reduce(
452
+ (best, a) => (lru(a) < lru(best) ? a : best),
453
+ candidates[0]!,
454
+ );
445
455
  }
446
456
 
447
457
  /** Pick an account for a kind (default + extras), applying rotation state. */
@@ -537,6 +547,16 @@ async function copyDirInto(
537
547
  }
538
548
  };
539
549
  await run(["/bin/mkdir", "-p", containerDir], `mkdir ${containerDir}`);
550
+ // The mkdir above runs as root, but a MACHINE copy extracts as the ssh
551
+ // user (node) — into a root-owned directory it dies with "Cannot open:
552
+ // Permission denied" and the account never authenticates (live
553
+ // 2026-09-07, first scoped Codex account on the shared host). Hand the
554
+ // directory to node BEFORE the copy; this also repairs a directory an
555
+ // earlier failed attempt left root-owned on a still-running machine.
556
+ await run(
557
+ ["/bin/chown", "-R", "node:node", containerDir],
558
+ `chown -R node:node ${containerDir}`,
559
+ );
540
560
  await environment.copy({
541
561
  direction: "into",
542
562
  source: hostDir,
@@ -653,6 +673,30 @@ export async function provisionEngineAccounts(
653
673
  return { copied, failed, mayHaveMutated };
654
674
  }
655
675
 
676
+ /** Priority order within (scope, kind): position, then age. Rows from before
677
+ * manage-v1 all carry 0 and therefore keep their creation order. */
678
+ function compareAccountRows(
679
+ a: { position?: number | null; createdAt: number; id: string },
680
+ b: { position?: number | null; createdAt: number; id: string },
681
+ ): number {
682
+ return (
683
+ (a.position ?? 0) - (b.position ?? 0) ||
684
+ a.createdAt - b.createdAt ||
685
+ a.id.localeCompare(b.id)
686
+ );
687
+ }
688
+
689
+ /** New accounts join the END of their (scope, kind) order. */
690
+ function nextPosition(kind: string, scope: string): number {
691
+ let max = -1;
692
+ for (const row of getDb().select().from(schema.engineAccounts).all()) {
693
+ if (row.kind !== kind) continue;
694
+ if (((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) !== scope) continue;
695
+ max = Math.max(max, (row as { position?: number | null }).position ?? 0);
696
+ }
697
+ return max + 1;
698
+ }
699
+
656
700
  // ---------------------------------------------------------------------------
657
701
  // Mutations (host UI).
658
702
  // ---------------------------------------------------------------------------
@@ -705,6 +749,7 @@ export function addEngineAccount(
705
749
  configDir: null,
706
750
  createdAt: now,
707
751
  scope,
752
+ position: nextPosition(kind, scope),
708
753
  })
709
754
  .run();
710
755
  return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
@@ -723,6 +768,7 @@ export function addEngineAccount(
723
768
  configDir: null,
724
769
  createdAt: now,
725
770
  scope,
771
+ position: nextPosition(kind, scope),
726
772
  })
727
773
  .run();
728
774
  return {
@@ -768,6 +814,7 @@ export function addEngineAccount(
768
814
  configDir: hostDir,
769
815
  createdAt: now,
770
816
  scope,
817
+ position: nextPosition(kind, scope),
771
818
  })
772
819
  .run();
773
820
  return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
@@ -842,6 +889,7 @@ export function addCodexAccountFromAuthJson(
842
889
  configDir: hostDir,
843
890
  createdAt: Date.now(),
844
891
  scope,
892
+ position: nextPosition("codex", scope),
845
893
  })
846
894
  .run();
847
895
  return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
@@ -890,3 +938,78 @@ export function removeEngineAccount(
890
938
  }
891
939
  return { ok: true, message: `Removed "${row.label}".` };
892
940
  }
941
+
942
+ /** Manage-v1: relabel an extra account. Same scope rule as removal. */
943
+ export function renameEngineAccount(
944
+ id: string,
945
+ label: string,
946
+ scope?: string,
947
+ ): { ok: boolean; message: string } {
948
+ if (id.startsWith("default:")) {
949
+ return { ok: false, message: "The default account cannot be renamed." };
950
+ }
951
+ const trimmed = label.trim();
952
+ if (!isEngineAccountLabel(trimmed)) {
953
+ return { ok: false, message: "Give the account a label (up to 64 characters)." };
954
+ }
955
+ const row = getDb()
956
+ .select()
957
+ .from(schema.engineAccounts)
958
+ .where(eq(schema.engineAccounts.id, id))
959
+ .get();
960
+ if (!row) return { ok: false, message: "That account no longer exists." };
961
+ if (!accountRemovableInScope(row, scope)) {
962
+ return { ok: false, message: "That account belongs to a different scope." };
963
+ }
964
+ getDb()
965
+ .update(schema.engineAccounts)
966
+ .set({ label: trimmed })
967
+ .where(eq(schema.engineAccounts.id, id))
968
+ .run();
969
+ return { ok: true, message: `Renamed to "${trimmed}".` };
970
+ }
971
+
972
+ /**
973
+ * Manage-v1: set the priority order of `kind`'s extra accounts in `scope`.
974
+ * `ids` must be exactly the set of that kind's rows in the scope — a stale
975
+ * list (an account added or removed meanwhile) is refused rather than
976
+ * silently partially applied.
977
+ */
978
+ export function reorderEngineAccounts(
979
+ kind: string,
980
+ ids: readonly string[],
981
+ scope: string = HOST_ACCOUNT_SCOPE,
982
+ ): { ok: boolean; message: string } {
983
+ if (!isEngineAccountScope(scope)) {
984
+ return { ok: false, message: "The account scope is invalid." };
985
+ }
986
+ const rows = getDb()
987
+ .select()
988
+ .from(schema.engineAccounts)
989
+ .all()
990
+ .filter(
991
+ (r) =>
992
+ r.kind === kind &&
993
+ ((r as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE) === scope,
994
+ );
995
+ const have = new Set(rows.map((r) => r.id));
996
+ const want = new Set(ids);
997
+ if (
998
+ have.size !== want.size ||
999
+ ids.length !== want.size ||
1000
+ [...have].some((id) => !want.has(id))
1001
+ ) {
1002
+ return {
1003
+ ok: false,
1004
+ message: "The account list changed — refresh and try again.",
1005
+ };
1006
+ }
1007
+ ids.forEach((id, index) => {
1008
+ getDb()
1009
+ .update(schema.engineAccounts)
1010
+ .set({ position: index })
1011
+ .where(eq(schema.engineAccounts.id, id))
1012
+ .run();
1013
+ });
1014
+ return { ok: true, message: "Order saved." };
1015
+ }
@@ -166,7 +166,7 @@ export class EngineConnectManager {
166
166
  scope ?? HOST_ACCOUNT_SCOPE,
167
167
  );
168
168
  if (added.ok) this.afterMutation(engine, false);
169
- this.finish(operation, added.ok, added.message);
169
+ this.finish(operation, added.ok, added.message, added.id);
170
170
  return;
171
171
  }
172
172
  // ADR-131 D3: everything below writes the host's DEFAULT slot or re-detects
@@ -207,6 +207,7 @@ export class EngineConnectManager {
207
207
  operation: ConnectOperation,
208
208
  ok: boolean,
209
209
  message: string,
210
+ accountId?: string,
210
211
  ): void {
211
212
  if (operation.done) return;
212
213
  operation.done = true;
@@ -221,6 +222,7 @@ export class EngineConnectManager {
221
222
  phase: "done",
222
223
  ok,
223
224
  message,
225
+ ...(ok && accountId !== undefined ? { accountId } : {}),
224
226
  });
225
227
  }
226
228
  }
@@ -151,12 +151,12 @@ export interface EngineLoginSeams {
151
151
  label: string,
152
152
  token: string,
153
153
  scope: string,
154
- ): { ok: boolean; message: string };
154
+ ): { ok: boolean; message: string; id?: string };
155
155
  addCodexAccount(
156
156
  label: string,
157
157
  authJson: string,
158
158
  scope: string,
159
- ): { ok: boolean; message: string };
159
+ ): { ok: boolean; message: string; id?: string };
160
160
  refreshEngineAccountAgents(engine: "claude"): Promise<void>;
161
161
  reinjectCodexRunningTasks(): Promise<void>;
162
162
  ownerHome(): string;
@@ -868,6 +868,7 @@ export class EngineLoginManager {
868
868
  opId: operation.opId,
869
869
  engine: operation.engine,
870
870
  phase: "awaiting_confirmation",
871
+ code: device.userCode,
871
872
  message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
872
873
  });
873
874
  return;
@@ -983,6 +984,7 @@ export class EngineLoginManager {
983
984
  engine: operation.engine,
984
985
  phase: "succeeded",
985
986
  message: added.message,
987
+ ...(added.id === undefined ? {} : { accountId: added.id }),
986
988
  }
987
989
  : failedEvent(
988
990
  operation.opId,
@@ -1039,7 +1041,7 @@ export class EngineLoginManager {
1039
1041
  if (operation.accountLabel !== null) {
1040
1042
  // ADR-116: labeled capture — the auth.json this login produced becomes
1041
1043
  // an isolated extra account instead of overwriting ~/.codex.
1042
- let added: { ok: boolean; message: string };
1044
+ let added: { ok: boolean; message: string; id?: string };
1043
1045
  try {
1044
1046
  const contents = await readFile(
1045
1047
  join(tempDir, "codex", "auth.json"),
@@ -1063,6 +1065,7 @@ export class EngineLoginManager {
1063
1065
  engine: operation.engine,
1064
1066
  phase: "succeeded",
1065
1067
  message: added.message,
1068
+ ...(added.id === undefined ? {} : { accountId: added.id }),
1066
1069
  }
1067
1070
  : failedEvent(
1068
1071
  operation.opId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.102",
3
+ "version": "0.9.104",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -118,7 +118,11 @@ export type {
118
118
  TaskUpResult,
119
119
  } from "../lib/agent";
120
120
 
121
- import { removeEngineAccount } from "../lib/engine-accounts";
121
+ import {
122
+ removeEngineAccount,
123
+ renameEngineAccount,
124
+ reorderEngineAccounts,
125
+ } from "../lib/engine-accounts";
122
126
 
123
127
  export {
124
128
  parseMentions,
@@ -1239,6 +1243,34 @@ export const hostCommands: HostCommands = {
1239
1243
  }
1240
1244
  },
1241
1245
 
1246
+ async engineAccountRename(ctx, input) {
1247
+ logCommand(ctx, "engineAccountRename", input.id);
1248
+ try {
1249
+ const result = renameEngineAccount(input.id, input.label, input.scope);
1250
+ return ok(
1251
+ result.ok
1252
+ ? { renamed: true }
1253
+ : { renamed: false, message: result.message },
1254
+ );
1255
+ } catch (err) {
1256
+ return failFromUnknown(err);
1257
+ }
1258
+ },
1259
+
1260
+ async engineAccountReorder(ctx, input) {
1261
+ logCommand(ctx, "engineAccountReorder", input.kind);
1262
+ try {
1263
+ const result = reorderEngineAccounts(input.kind, input.ids, input.scope);
1264
+ return ok(
1265
+ result.ok
1266
+ ? { reordered: true }
1267
+ : { reordered: false, message: result.message },
1268
+ );
1269
+ } catch (err) {
1270
+ return failFromUnknown(err);
1271
+ }
1272
+ },
1273
+
1242
1274
  async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
1243
1275
  logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
1244
1276
  try {
package/src/main.ts CHANGED
@@ -186,6 +186,7 @@ import {
186
186
  HOST_TASK_STOP_PROTOCOL_FEATURE,
187
187
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
188
188
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
189
+ HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
189
190
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
190
191
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
191
192
  isEngineAccountScope,
@@ -230,6 +231,7 @@ import {
230
231
  type TaskLaunchInput,
231
232
  type FilesOpInput,
232
233
  engineAccountScopeFromWire,
234
+ isEngineAccountLabel,
233
235
  } from "./protocol";
234
236
 
235
237
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
@@ -609,6 +611,7 @@ function buildCapabilities(): HostCapabilities {
609
611
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
610
612
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
611
613
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
614
+ HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
612
615
  // The echo adapter cannot execute the in-task CLI. Advertising typed
613
616
  // dispatch in mock mode would let the composer create a Secretary that
614
617
  // has no way to wake crew.
@@ -2443,9 +2446,70 @@ async function dispatchCommand(
2443
2446
  if (removal.ok) sendCapabilities();
2444
2447
  return removal;
2445
2448
  }
2449
+ case "engineAccountRename": {
2450
+ const renamed = await hostCommands.engineAccountRename(
2451
+ ctx,
2452
+ expectEngineAccountRenameInput(args, 0),
2453
+ );
2454
+ if (renamed.ok) sendCapabilities();
2455
+ return renamed;
2456
+ }
2457
+ case "engineAccountReorder": {
2458
+ const reordered = await hostCommands.engineAccountReorder(
2459
+ ctx,
2460
+ expectEngineAccountReorderInput(args, 0),
2461
+ );
2462
+ if (reordered.ok) sendCapabilities();
2463
+ return reordered;
2464
+ }
2446
2465
  }
2447
2466
  }
2448
2467
 
2468
+ function expectEngineAccountRenameInput(
2469
+ args: unknown[],
2470
+ index: number,
2471
+ ): { id: string; label: string; scope?: string } {
2472
+ const base = expectEngineAccountRemoveInput(args, index);
2473
+ const label = (args[index] as { label?: unknown }).label;
2474
+ if (!isEngineAccountLabel(label)) {
2475
+ throw new Error(`invalid command args: bad account label at ${index}`);
2476
+ }
2477
+ return { ...base, label };
2478
+ }
2479
+
2480
+ function expectEngineAccountReorderInput(
2481
+ args: unknown[],
2482
+ index: number,
2483
+ ): { kind: string; ids: string[]; scope?: string } {
2484
+ const value = args[index];
2485
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2486
+ throw new Error(`invalid command args: expected reorder input at ${index}`);
2487
+ }
2488
+ const { kind, ids, scope } = value as {
2489
+ kind?: unknown;
2490
+ ids?: unknown;
2491
+ scope?: unknown;
2492
+ };
2493
+ if (typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)) {
2494
+ throw new Error(`invalid command args: bad account kind at ${index}`);
2495
+ }
2496
+ if (
2497
+ !Array.isArray(ids) ||
2498
+ ids.length > 64 ||
2499
+ ids.some((id) => typeof id !== "string" || id.length === 0 || id.length > 256)
2500
+ ) {
2501
+ throw new Error(`invalid command args: bad account id list at ${index}`);
2502
+ }
2503
+ if (scope !== undefined && !isEngineAccountScope(scope)) {
2504
+ throw new Error(`invalid command args: bad account scope at ${index}`);
2505
+ }
2506
+ return {
2507
+ kind,
2508
+ ids: ids as string[],
2509
+ ...(scope !== undefined ? { scope } : {}),
2510
+ };
2511
+ }
2512
+
2449
2513
  function expectEngineAccountRemoveInput(
2450
2514
  args: unknown[],
2451
2515
  index: number,
@@ -2981,6 +3045,8 @@ const HOST_COMMAND_NAMES = {
2981
3045
  appendTranscript: true,
2982
3046
  previewEnsure: true,
2983
3047
  engineAccountRemove: true,
3048
+ engineAccountRename: true,
3049
+ engineAccountReorder: true,
2984
3050
  } satisfies Record<keyof HostCommands, true>;
2985
3051
 
2986
3052
  function isHostCommand(command: string): command is keyof HostCommands {
package/src/protocol.ts CHANGED
@@ -109,6 +109,12 @@ export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
109
109
  * and stamps `scope` on every EngineAccountSummary it advertises. The cloud
110
110
  * sends none of those to a host without the feature (the v1 exact-shape
111
111
  * guards would reject the frames). */
112
+ /** Account management v1 (host 0.9.104): `engineAccountRename` and
113
+ * `engineAccountReorder` commands; login `succeeded` events carry the created
114
+ * `accountId` for labeled captures; codex `awaiting_confirmation` carries the
115
+ * device `code`. Extras are advertised in priority order (position). */
116
+ export const HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE =
117
+ "host-engine-account-manage-v1";
112
118
  export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
113
119
  "host-engine-account-scopes-v1";
114
120
  /** The owner-level account scope; every pre-scope account lives here. */
@@ -386,11 +392,15 @@ export type EngineLoginEventFrame =
386
392
  phase: "authorize";
387
393
  url: string;
388
394
  errorCode?: never;
395
+ code?: never;
396
+ accountId?: never;
389
397
  })
390
398
  | (EngineLoginEventBase & {
391
399
  phase: "failed" | "timed_out";
392
400
  url?: never;
393
401
  errorCode?: EngineLoginErrorCode;
402
+ code?: never;
403
+ accountId?: never;
394
404
  })
395
405
  | (EngineLoginEventBase & {
396
406
  phase:
@@ -404,6 +414,12 @@ export type EngineLoginEventFrame =
404
414
  | "cancelled";
405
415
  url?: never;
406
416
  errorCode?: never;
417
+ /** Device-code flow only (`awaiting_confirmation`): the one-time code
418
+ * the operator enters on the provider's page — shown large in the UI. */
419
+ code?: string;
420
+ /** `succeeded` of a LABELED capture: the id of the account it created,
421
+ * so the client can rename/order it right away. */
422
+ accountId?: string;
407
423
  });
408
424
 
409
425
  /** Rolling-safe, secret-free engine state. v1 intentionally makes no claim
@@ -482,6 +498,9 @@ export interface EngineConnectEventFrame {
482
498
  line?: string;
483
499
  ok?: boolean;
484
500
  message?: string;
501
+ /** Manage-v1: `done` of a LABELED connect — the id of the account it
502
+ * created, so the client can name/order it right away. */
503
+ accountId?: string;
485
504
  }
486
505
 
487
506
  export function isEngineConnectKind(
@@ -541,7 +560,7 @@ export function isEngineConnectEventFrame(
541
560
  const frame = exactWireObject(
542
561
  value,
543
562
  ["kind", "opId", "engine", "phase"],
544
- ["line", "ok", "message"],
563
+ ["line", "ok", "message", "accountId"],
545
564
  );
546
565
  if (
547
566
  !frame ||
@@ -551,6 +570,16 @@ export function isEngineConnectEventFrame(
551
570
  ) {
552
571
  return false;
553
572
  }
573
+ if (
574
+ Object.hasOwn(frame, "accountId") &&
575
+ (frame.phase !== "done" ||
576
+ frame.ok !== true ||
577
+ typeof frame.accountId !== "string" ||
578
+ frame.accountId.length < 1 ||
579
+ frame.accountId.length > 256)
580
+ ) {
581
+ return false;
582
+ }
554
583
  if (frame.phase === "line") {
555
584
  return (
556
585
  typeof frame.line === "string" &&
@@ -773,6 +802,16 @@ export function isEngineLoginStartFrame(
773
802
  /** ADR-116 account labels: operator-chosen display names, never secrets.
774
803
  * Single-line, bounded, and visibly printable so they render safely in every
775
804
  * surface (capability frames, UI chips, log lines). */
805
+ /** A provider device code: short, printable, no whitespace (`AC57-MDV9G`). */
806
+ export function isEngineLoginDeviceCode(value: unknown): value is string {
807
+ return (
808
+ typeof value === "string" &&
809
+ value.length >= 4 &&
810
+ value.length <= 32 &&
811
+ /^[A-Za-z0-9-]+$/.test(value)
812
+ );
813
+ }
814
+
776
815
  export function isEngineAccountLabel(value: unknown): value is string {
777
816
  return (
778
817
  typeof value === "string" &&
@@ -826,7 +865,7 @@ export function isEngineLoginEventFrame(
826
865
  const frame = exactWireObject(
827
866
  value,
828
867
  ["kind", "opId", "engine", "phase"],
829
- ["url", "errorCode", "message"],
868
+ ["url", "errorCode", "message", "code", "accountId"],
830
869
  );
831
870
  if (
832
871
  !frame ||
@@ -837,6 +876,22 @@ export function isEngineLoginEventFrame(
837
876
  ) {
838
877
  return false;
839
878
  }
879
+ // Manage-v1 extras ride exactly one phase each.
880
+ if (
881
+ Object.hasOwn(frame, "code") &&
882
+ (frame.phase !== "awaiting_confirmation" || !isEngineLoginDeviceCode(frame.code))
883
+ ) {
884
+ return false;
885
+ }
886
+ if (
887
+ Object.hasOwn(frame, "accountId") &&
888
+ (frame.phase !== "succeeded" ||
889
+ typeof frame.accountId !== "string" ||
890
+ frame.accountId.length < 1 ||
891
+ frame.accountId.length > 256)
892
+ ) {
893
+ return false;
894
+ }
840
895
  if (frame.phase === "authorize") {
841
896
  return (
842
897
  isHttpsUrl(frame.url) &&
@@ -1422,6 +1477,19 @@ export interface HostCommands {
1422
1477
  ctx: CommandContext,
1423
1478
  input: { id: string; scope?: string },
1424
1479
  ): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
1480
+ /** Manage-v1: relabel an extra account (never `default:*`); a scope may
1481
+ * rename only its own accounts. Re-advertises on success. */
1482
+ engineAccountRename(
1483
+ ctx: CommandContext,
1484
+ input: { id: string; label: string; scope?: string },
1485
+ ): Promise<HostCommandResult<{ renamed: boolean; message?: string }>>;
1486
+ /** Manage-v1: set the priority order of a kind's extra accounts within a
1487
+ * scope — `ids` must be exactly that set. Tasks pick the first healthy
1488
+ * account in this order. Re-advertises on success. */
1489
+ engineAccountReorder(
1490
+ ctx: CommandContext,
1491
+ input: { kind: string; ids: string[]; scope?: string },
1492
+ ): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
1425
1493
  cloneRepo(
1426
1494
  ctx: CommandContext,
1427
1495
  input: CloneRepoInput,