@paradow/ledge 0.26.0 → 0.28.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.
Files changed (32) hide show
  1. package/dist/cli/{action-SCMMRWYI.js → action-NQRSZEC5.js} +4 -4
  2. package/dist/cli/{attach-EFIN5VU6.js → attach-KXOSFUHO.js} +8 -8
  3. package/dist/cli/{bridge-QT5X6TWJ.js → bridge-XCTXVWKH.js} +1 -1
  4. package/dist/cli/{chunk-6F6JTPMC.js → chunk-3CF4FQWG.js} +2 -2
  5. package/dist/cli/{chunk-JHUIHBC7.js → chunk-3NQNWGDX.js} +2 -2
  6. package/dist/cli/{chunk-5PTC732E.js → chunk-CIQAFCX3.js} +3 -3
  7. package/dist/cli/{chunk-SYXW4YB6.js → chunk-CR3LGTZQ.js} +3 -3
  8. package/dist/cli/{chunk-II6VRGLU.js → chunk-JHFKBKSO.js} +1 -1
  9. package/dist/cli/{chunk-L3GYGJRE.js → chunk-JLUHY5AB.js} +3 -3
  10. package/dist/cli/{chunk-PTY4LXTG.js → chunk-LG3XFIPV.js} +1 -1
  11. package/dist/cli/{chunk-2V2OJR2N.js → chunk-OYDTU6AP.js} +373 -58
  12. package/dist/cli/{chunk-HCTXTN2X.js → chunk-QSRB3F5Y.js} +2 -2
  13. package/dist/cli/{chunk-WYVMCF2K.js → chunk-RYZ6FHYT.js} +5 -5
  14. package/dist/cli/chunk-UME2F7VM.js +6 -0
  15. package/dist/cli/{chunk-QRD74KPO.js → chunk-VKRFHDRK.js} +151 -13
  16. package/dist/cli/{chunk-NYGLT5G2.js → chunk-VMGACWNP.js} +1 -1
  17. package/dist/cli/{chunk-TYNRKZFE.js → chunk-ZI47U5CQ.js} +2 -2
  18. package/dist/cli/{daemon-U6NA6PVD.js → daemon-ZUHJKLSL.js} +34 -15
  19. package/dist/cli/{decide-XOUTZT3S.js → decide-OSSTI3FE.js} +3 -3
  20. package/dist/cli/{handover-6CQKUZJV.js → handover-2G7OEWIG.js} +4 -4
  21. package/dist/cli/{hook-ZIJACTCF.js → hook-XY4E5H7O.js} +9 -9
  22. package/dist/cli/index.js +16 -16
  23. package/dist/cli/{launch-QWPA2P66.js → launch-7WOARLZM.js} +8 -8
  24. package/dist/cli/{send-T5IOOVGX.js → send-RUHGKLMA.js} +4 -4
  25. package/dist/cli/{status-EZPFE5CL.js → status-4Z2VJFID.js} +2 -2
  26. package/dist/cli/{task-B2QVGHDF.js → task-2GESYTOL.js} +4 -4
  27. package/dist/cli/{team-V6VPO3CB.js → team-MTSKNMAE.js} +6 -6
  28. package/dist/cli/{tui-MM64KSQ2.js → tui-4IRYQXYU.js} +8 -8
  29. package/dist/cli/{version-SK4VC4AE.js → version-L2Y7MRH6.js} +1 -1
  30. package/dist/cli/{who-XXZUCJPD.js → who-PSKHASMK.js} +3 -3
  31. package/package.json +1 -1
  32. package/dist/cli/chunk-3PLC745L.js +0 -6
@@ -10,7 +10,7 @@ import {
10
10
  templateExists,
11
11
  templateId,
12
12
  templateText
13
- } from "./chunk-HCTXTN2X.js";
13
+ } from "./chunk-QSRB3F5Y.js";
14
14
  import {
15
15
  NO_ALWAYS,
16
16
  NO_NOTE,
@@ -39,7 +39,7 @@ import {
39
39
  shownCommand,
40
40
  teammates,
41
41
  withImages
42
- } from "./chunk-TYNRKZFE.js";
42
+ } from "./chunk-ZI47U5CQ.js";
43
43
  import {
44
44
  definitionFiles,
45
45
  definitionHash,
@@ -47,7 +47,7 @@ import {
47
47
  loadRole,
48
48
  quoteScalar,
49
49
  repoRoot
50
- } from "./chunk-II6VRGLU.js";
50
+ } from "./chunk-JHFKBKSO.js";
51
51
  import {
52
52
  PARENT_AGENT_VARS,
53
53
  agentEnv,
@@ -57,7 +57,7 @@ import {
57
57
  labelTranscript,
58
58
  resumeLabel,
59
59
  spawnSession
60
- } from "./chunk-PTY4LXTG.js";
60
+ } from "./chunk-LG3XFIPV.js";
61
61
  import {
62
62
  ACTIONS,
63
63
  ATTACHMENT_ID,
@@ -66,6 +66,8 @@ import {
66
66
  AttachmentRefSchema,
67
67
  BRIEF_MAX,
68
68
  CHAT_HISTORY_BUDGET,
69
+ CHECKLIST_TASKS_MAX,
70
+ CHECKLIST_TEXT_MAX,
69
71
  CONTEXT_NEAR,
70
72
  ContextPrefsSchema,
71
73
  DEFAULT_PROJECTS,
@@ -80,6 +82,7 @@ import {
80
82
  MESSAGE_MAX,
81
83
  MachinesSchema,
82
84
  ModelIdSchema,
85
+ ModelRefusalSchema,
83
86
  NEVER_PROPOSED,
84
87
  NOTE_NOT_TYPED,
85
88
  OperationIdSchema,
@@ -88,6 +91,7 @@ import {
88
91
  PREVIEW_MAX,
89
92
  PROTOCOL_PORT,
90
93
  PhoneClientMessageSchema,
94
+ PhoneModelChoiceSchema,
91
95
  PhoneRoleSchema,
92
96
  PhoneTemplateSchema,
93
97
  ProjectsSchema,
@@ -95,6 +99,7 @@ import {
95
99
  RefusalCodeSchema,
96
100
  RoleIdSchema,
97
101
  RouteResultSchema,
102
+ SEMVER,
98
103
  SESSION_KEY_VAR,
99
104
  SNAPSHOT_BODY_MAX,
100
105
  SshAliasSchema,
@@ -102,6 +107,7 @@ import {
102
107
  THREAD_BUDGET,
103
108
  THREAD_ITEMS_MAX,
104
109
  THREAD_TEXT_MAX,
110
+ TODO_TOOLS,
105
111
  TOOL_TEXT_MAX,
106
112
  TOPIC_MAX,
107
113
  TeamSchema,
@@ -113,6 +119,7 @@ import {
113
119
  backendOf,
114
120
  canonicalJson,
115
121
  contextNear,
122
+ doneCount,
116
123
  ensureDaemonToken,
117
124
  extractProposal,
118
125
  givenByLedge,
@@ -120,6 +127,7 @@ import {
120
127
  isArchived,
121
128
  isTeamLive,
122
129
  isTeamsOperation,
130
+ latestTodos,
123
131
  offers,
124
132
  ompApprovalFor,
125
133
  padPlain,
@@ -131,6 +139,7 @@ import {
131
139
  parseServerMessage,
132
140
  parseTaskBlock,
133
141
  parseTeamBlock,
142
+ pcSubject,
134
143
  pendingKey,
135
144
  pickName,
136
145
  projectOf,
@@ -145,7 +154,7 @@ import {
145
154
  teamsPurpose,
146
155
  tokenEquals,
147
156
  transmissions
148
- } from "./chunk-QRD74KPO.js";
157
+ } from "./chunk-VKRFHDRK.js";
149
158
  import {
150
159
  log
151
160
  } from "./chunk-6IYPRUQG.js";
@@ -2239,7 +2248,7 @@ function teamsJournal(path) {
2239
2248
 
2240
2249
  // src/daemon/server.ts
2241
2250
  import { createServer } from "http";
2242
- import { randomUUID as randomUUID5 } from "crypto";
2251
+ import { randomUUID as randomUUID6 } from "crypto";
2243
2252
  import { homedir as homedir7 } from "os";
2244
2253
  import { basename as basename6, join as join18, resolve as resolve8 } from "path";
2245
2254
  import { existsSync } from "fs";
@@ -2276,12 +2285,12 @@ var RpcError = class extends Error {
2276
2285
  };
2277
2286
  function lineReader(max, onLine, onBad) {
2278
2287
  const decoder = new TextDecoder("utf-8", { fatal: true });
2279
- let parts = [];
2288
+ let parts2 = [];
2280
2289
  let size = 0;
2281
2290
  let skipping = false;
2282
2291
  const flush = () => {
2283
- const whole = parts.length === 1 ? parts[0] : Buffer.concat(parts);
2284
- parts = [];
2292
+ const whole = parts2.length === 1 ? parts2[0] : Buffer.concat(parts2);
2293
+ parts2 = [];
2285
2294
  size = 0;
2286
2295
  let text;
2287
2296
  try {
@@ -2301,10 +2310,10 @@ function lineReader(max, onLine, onBad) {
2301
2310
  size += end - from;
2302
2311
  if (size > max) {
2303
2312
  skipping = true;
2304
- parts = [];
2313
+ parts2 = [];
2305
2314
  size = 0;
2306
2315
  onBad(`frame over ${max} bytes`);
2307
- } else if (end > from) parts.push(chunk.subarray(from, end));
2316
+ } else if (end > from) parts2.push(chunk.subarray(from, end));
2308
2317
  }
2309
2318
  if (nl === -1) return;
2310
2319
  if (skipping) skipping = false;
@@ -3959,9 +3968,9 @@ function createForge(deps = {}) {
3959
3968
  index.push(`- ${name}: no repository on this machine`);
3960
3969
  return;
3961
3970
  }
3962
- const parts = await Promise.all(local.map((r) => section(r.path)));
3963
- await writeFile5(join9(ctx.dir, file), parts.flatMap((s) => s.lines).join("\n"));
3964
- const sources = parts.flatMap((s) => s.source ? [s.source] : []);
3971
+ const parts2 = await Promise.all(local.map((r) => section(r.path)));
3972
+ await writeFile5(join9(ctx.dir, file), parts2.flatMap((s) => s.lines).join("\n"));
3973
+ const sources = parts2.flatMap((s) => s.source ? [s.source] : []);
3965
3974
  ctx.steps.set(file, { kind: "repo", name, path: sources.join(", ") || file });
3966
3975
  index.push(`- ${name}: ${file}`);
3967
3976
  }));
@@ -5061,18 +5070,18 @@ async function readAt(request, path) {
5061
5070
  if (!stat4.isFile()) return failure(request, "not_file");
5062
5071
  if (stat4.size > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
5063
5072
  const chunk = Buffer.allocUnsafe(64 * 1024);
5064
- const parts = [];
5073
+ const parts2 = [];
5065
5074
  let bytes4 = 0;
5066
5075
  while (bytes4 <= DOCUMENT_MAX_BYTES) {
5067
5076
  const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, DOCUMENT_MAX_BYTES + 1 - bytes4), null);
5068
5077
  if (!bytesRead) break;
5069
- parts.push(Buffer.from(chunk.subarray(0, bytesRead)));
5078
+ parts2.push(Buffer.from(chunk.subarray(0, bytesRead)));
5070
5079
  bytes4 += bytesRead;
5071
5080
  }
5072
5081
  if (bytes4 > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
5073
5082
  let text;
5074
5083
  try {
5075
- text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, bytes4));
5084
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts2, bytes4));
5076
5085
  } catch {
5077
5086
  return failure(request, "invalid_utf8");
5078
5087
  }
@@ -5673,6 +5682,19 @@ function serveSsh(ws, alias, opts = {}) {
5673
5682
 
5674
5683
  // src/mobile/gateway.ts
5675
5684
  import { hostname } from "os";
5685
+ import { randomUUID as randomUUID5 } from "crypto";
5686
+
5687
+ // ../ui/src/semver.ts
5688
+ var parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
5689
+ function compareVersions(a, b2) {
5690
+ const x = parts(a);
5691
+ const y = parts(b2);
5692
+ for (let i = 0; i < 3; i++) {
5693
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
5694
+ if (d) return d > 0 ? 1 : -1;
5695
+ }
5696
+ return a === b2 ? 0 : a.includes("-") ? -1 : b2.includes("-") ? 1 : 0;
5697
+ }
5676
5698
 
5677
5699
  // src/mobile/chat.ts
5678
5700
  var HISTORY_TURNS = 50;
@@ -6011,6 +6033,8 @@ function fingerprint(m) {
6011
6033
  f.rows ?? null,
6012
6034
  ...f.row !== void 0 || f.team !== void 0 || f.task !== void 0 ? [f.row ?? null, f.team ?? null, f.task ?? null] : [],
6013
6035
  ...f.attachments?.length ? [f.attachments] : [],
6036
+ // A model change is its model and effort: the same id for another model is another operation (null, the agent's default, too).
6037
+ ...m.type === "session.model" ? [m.model, m.effort] : [],
6014
6038
  // A role or team operation is its whole content: the same id with another prompt, seats or base is another operation.
6015
6039
  ...isTeamsOperation(m) ? [teamsContent(m)] : []
6016
6040
  ]);
@@ -6038,12 +6062,14 @@ var OperationRegistry = class {
6038
6062
  return { kind: "fresh", record: { ...record } };
6039
6063
  }
6040
6064
  /** An operation already forgotten (expired) is left forgotten. */
6041
- finish(op, status, reason) {
6065
+ finish(op, status, reason, models) {
6042
6066
  const r = this.records.get(op);
6043
6067
  if (!r) return;
6044
6068
  r.status = status;
6045
6069
  if (reason) r.reason = reason;
6046
6070
  else delete r.reason;
6071
+ if (models) r.models = models;
6072
+ else delete r.models;
6047
6073
  }
6048
6074
  get(op) {
6049
6075
  this.expire();
@@ -6195,7 +6221,27 @@ function toPhoneRequest(s, bodyMax) {
6195
6221
  return toPhonePending(s.pending, pendingKey(s.pending), verdict.risk, verdict.reason, bodyMax);
6196
6222
  }
6197
6223
  var toPhoneFullRequest = (s) => toPhoneRequest(s, PENDING_BODY_MAX);
6198
- function toPhoneSession(s, readAt2) {
6224
+ var checklistCut = (todos) => todos.length > CHECKLIST_TASKS_MAX || todos.some((t) => t.text.length > CHECKLIST_TEXT_MAX);
6225
+ function toPhoneChecklist(todos) {
6226
+ if (!todos) return null;
6227
+ return {
6228
+ tasks: todos.slice(0, CHECKLIST_TASKS_MAX).map((t) => ({ text: clip(t.text, CHECKLIST_TEXT_MAX).text, status: t.status })),
6229
+ done: doneCount(todos),
6230
+ total: todos.length,
6231
+ ...checklistCut(todos) ? { cut: true } : {}
6232
+ };
6233
+ }
6234
+ function checklistSummary(todos) {
6235
+ if (!todos) return void 0;
6236
+ const current2 = todos.find((t) => t.status === "in_progress");
6237
+ return {
6238
+ done: doneCount(todos),
6239
+ total: todos.length,
6240
+ ...current2 ? { current: clip(current2.text, CHECKLIST_TEXT_MAX).text } : {},
6241
+ ...checklistCut(todos) ? { cut: true } : {}
6242
+ };
6243
+ }
6244
+ function toPhoneSession(s, readAt2, checklist) {
6199
6245
  const pending = toPhoneRequest(s, SNAPSHOT_BODY_MAX);
6200
6246
  return {
6201
6247
  id: s.id,
@@ -6211,10 +6257,19 @@ function toPhoneSession(s, readAt2) {
6211
6257
  ...pickSet(s, ["repo", "branch", "worktree", "role", "roleLabel", "team", "context", "relieves"]),
6212
6258
  // Its relief's state only (context handover spec §3.2): the phone shows it, the PC starts it.
6213
6259
  ...s.handover ? { handover: { state: s.handover.state, ...s.handover.toName ? { toName: s.handover.toName.slice(0, 80) } : {} } } : {},
6260
+ // Its model, effort and the change waiting for its next free moment (#58), cut to the bounds of a model id and an effort.
6261
+ ...s.model !== void 0 ? { model: clip(s.model, MODEL_MAX).text } : {},
6262
+ ...s.effort !== void 0 ? { effort: clip(s.effort, EFFORT_MAX).text } : {},
6263
+ ...s.modelNext ? { modelNext: {
6264
+ ...s.modelNext.model !== void 0 ? { model: clip(s.modelNext.model, MODEL_MAX).text } : {},
6265
+ ...s.modelNext.effort !== void 0 ? { effort: clip(s.modelNext.effort, EFFORT_MAX).text } : {}
6266
+ } } : {},
6267
+ ...canSwitch(s) ? { canSwitch: true } : {},
6214
6268
  helpers: s.helpers.map((h) => ({ id: h.id, kind: h.kind, task: h.task, state: h.state, ...pickSet(h, ["finishedAt"]) })),
6215
6269
  // A preview only: the whole reply is in the thread.
6216
6270
  ...s.lastReply ? { lastReply: { text: clip(s.lastReply.text, TOOL_TEXT_MAX).text, at: s.lastReply.at, ...pickSet(s.lastReply, ["plan"]) } } : {},
6217
6271
  ...pending ? { pending } : {},
6272
+ ...checklist ? { checklist } : {},
6218
6273
  unread: !!s.lastReply && s.lastReply.at > readAt2
6219
6274
  };
6220
6275
  }
@@ -6435,10 +6490,39 @@ function toPhoneTask(x, owner, teamName) {
6435
6490
  };
6436
6491
  }
6437
6492
  var phoneTasks = (tasks, now) => tasks.filter((x) => x.giver === LEDGE_GIVER && (!taskClosed(x) || !x.seen && now - Date.parse(x.updatedAt) < 864e5)).slice(-200);
6493
+ var MODEL_MAX = 200;
6494
+ var EFFORT_MAX = 20;
6495
+ var MODELS_MAX = 40;
6496
+ function canSwitch(s) {
6497
+ return s.source === "launcher" && s.state !== "exited";
6498
+ }
6499
+ function toPhoneModels(models) {
6500
+ const out = [];
6501
+ for (const m of models) {
6502
+ if (out.length >= MODELS_MAX) break;
6503
+ const efforts = m.efforts?.filter((e) => EffortSchema.safeParse(e).success).slice(0, 8);
6504
+ const c = PhoneModelChoiceSchema.safeParse({
6505
+ id: m.id,
6506
+ label: clip(m.label, 80).text,
6507
+ ...m.description !== void 0 ? { description: clip(m.description, 200).text } : {},
6508
+ ...efforts ? { efforts } : {},
6509
+ ...pickSet(m, ["defaultEffort", "isDefault"])
6510
+ });
6511
+ if (c.success) out.push(c.data);
6512
+ }
6513
+ return out;
6514
+ }
6438
6515
 
6439
6516
  // src/mobile/teamsOps.ts
6440
6517
  var FAILED = { status: "failed" };
6441
6518
  var invalid = { status: "rejected", reason: "invalid" };
6519
+ function withSeats(models) {
6520
+ const seats = (models ?? []).flatMap((x) => {
6521
+ if (x.code) return [{ role: x.role, session: x.session, status: "refused", reason: x.code }];
6522
+ return x.when ? [{ role: x.role, session: x.session, status: x.when === "now" ? "applied" : "scheduled" }] : [];
6523
+ });
6524
+ return seats.length ? { models: seats } : {};
6525
+ }
6442
6526
  function createTeamsOps(deps) {
6443
6527
  const listing = async () => {
6444
6528
  let found;
@@ -6492,10 +6576,12 @@ function createTeamsOps(deps) {
6492
6576
  if (!m.code) return true;
6493
6577
  if (m.code === "missing") return FAILED;
6494
6578
  return { status: "rejected", reason: m.code };
6495
- case "team.changed":
6579
+ case "team.changed": {
6496
6580
  if (m.rid !== rid) return void 0;
6497
- if (m.reason && m.team && type === "team.compose") return { status: "applied", reason: "seat_failed" };
6498
- return !m.reason;
6581
+ const seats = withSeats(m.models);
6582
+ if (m.reason && m.team && type === "team.compose") return { status: "applied", reason: "seat_failed", ...seats };
6583
+ return m.reason ? FAILED : { status: "applied", ...seats };
6584
+ }
6499
6585
  case "team.launched":
6500
6586
  return m.rid === rid ? true : void 0;
6501
6587
  case "team.failed":
@@ -6690,12 +6776,14 @@ function verifyAssertion(a, cred, rp, challenge) {
6690
6776
  // src/mobile/gateway.ts
6691
6777
  var FLUSH_BEFORE_REVOKE_MS = 5e3;
6692
6778
  var REQUEST_NAME = /^[a-z][a-z.]{0,39}$/;
6779
+ var MODELS_WAIT_MS = 2e4;
6693
6780
  function createPhoneGateway(deps) {
6694
6781
  const { mobile, log: log2 } = deps;
6695
6782
  const startedAt = new Date(deps.now ? deps.now() : Date.now()).toISOString();
6696
6783
  const registry = new OperationRegistry();
6697
6784
  const followers = /* @__PURE__ */ new Map();
6698
6785
  let stopped2 = false;
6786
+ const modelFailures = /* @__PURE__ */ new Map();
6699
6787
  const follower = (deviceId) => {
6700
6788
  let f = followers.get(deviceId);
6701
6789
  if (!f) followers.set(deviceId, f = { subscribed: false, watching: null, reads: /* @__PURE__ */ new Map(), visible: false });
@@ -6738,7 +6826,17 @@ function createPhoneGateway(deps) {
6738
6826
  const s = deps.session(id);
6739
6827
  return s && !isArchived(s) ? s : void 0;
6740
6828
  };
6741
- const projected = (deviceId, s) => toPhoneSession(s, followers.get(deviceId)?.reads.get(s.id) ?? startedAt);
6829
+ const checklists = /* @__PURE__ */ new Map();
6830
+ const computeChecklist = (id) => {
6831
+ const todos = latestTodos(deps.thread(id) ?? []);
6832
+ return { list: toPhoneChecklist(todos), summary: checklistSummary(todos) };
6833
+ };
6834
+ const checklistOf = (id) => {
6835
+ let c = checklists.get(id);
6836
+ if (!c) checklists.set(id, c = computeChecklist(id));
6837
+ return c;
6838
+ };
6839
+ const projected = (deviceId, s) => toPhoneSession(s, followers.get(deviceId)?.reads.get(s.id) ?? startedAt, checklistOf(s.id).summary);
6742
6840
  const pushStatus = (deviceId) => {
6743
6841
  const key = mobile.relayPushKey();
6744
6842
  if (key === null) return "unavailable";
@@ -6750,14 +6848,15 @@ function createPhoneGateway(deps) {
6750
6848
  const push2 = pushStatus(deviceId);
6751
6849
  const f = followers.get(deviceId);
6752
6850
  if (f) f.pushStatus = push2;
6753
- return {
6851
+ const message = {
6754
6852
  type: "snapshot",
6755
6853
  protocol: PHONE_PROTOCOL,
6756
6854
  sessions: deps.sessions().filter((s) => !isArchived(s)).map((s) => projected(deviceId, s)),
6757
6855
  capabilities: capabilities(),
6758
- daemon: { version: deps.version, boot: deps.boot, name: clip(hostname(), 40).text },
6856
+ daemon: { version: deps.version, boot: deps.boot, name: clip(hostname(), 40).text, ...teamsOps ? { teams: true } : {}, ...deps.pc?.canUpdate ? { update: true } : {}, ...deps.pc?.canRestart ? { restart: true } : {}, checklist: true },
6759
6857
  device: { confirm: deps.credential(deviceId) !== null, push: push2, ...deps.chats ? { chat: true } : {}, ...deps.contextNear ? { contextNear: deps.contextNear() } : {} }
6760
6858
  };
6859
+ return deps.models ? { ...message, daemon: { ...message.daemon, models: true } } : message;
6761
6860
  };
6762
6861
  const phoneTeams = () => (deps.teams?.() ?? []).flatMap((t) => {
6763
6862
  const x = toPhoneTeam(t, deps.tasks?.() ?? [], deps.projectName ?? (() => null));
@@ -6779,6 +6878,8 @@ function createPhoneGateway(deps) {
6779
6878
  if (followers.get(deviceId)?.subscribed) {
6780
6879
  outbox.push(deviceId, snapshot(deviceId), "snapshot");
6781
6880
  teamsAndTasks(deviceId);
6881
+ const watching = followers.get(deviceId)?.watching;
6882
+ if (watching) outbox.push(deviceId, { type: "session.checklist", id: watching, checklist: checklistOf(watching).list }, `session.checklist:${watching}`);
6782
6883
  }
6783
6884
  },
6784
6885
  log: log2,
@@ -6787,9 +6888,27 @@ function createPhoneGateway(deps) {
6787
6888
  const push = (deviceId, msg, key) => {
6788
6889
  if (!stopped2) outbox.push(deviceId, msg, key);
6789
6890
  };
6790
- const result = (deviceId, op, status, reason) => {
6891
+ const replayModelFailures = (deviceId) => {
6892
+ for (const [id, f] of modelFailures) if (live3(id)) push(deviceId, { type: "session.model.failed", id, reason: f.reason });
6893
+ };
6894
+ const keepModelFailure = (m) => {
6895
+ if (m.type === "session.removed") {
6896
+ modelFailures.delete(m.id);
6897
+ return;
6898
+ }
6899
+ if (m.type === "session.updated") {
6900
+ const f = modelFailures.get(m.session.id);
6901
+ if (f && (isArchived(m.session) || f.model !== m.session.model || f.effort !== m.session.effort)) modelFailures.delete(m.session.id);
6902
+ return;
6903
+ }
6904
+ if (m.type !== "error" || m.request !== "session.model" || m.id === void 0) return;
6905
+ const reason = ModelRefusalSchema.safeParse(m.code);
6906
+ const s = live3(m.id);
6907
+ if (reason.success && s) modelFailures.set(m.id, { reason: reason.data, model: s.model, effort: s.effort });
6908
+ };
6909
+ const result = (deviceId, op, status, reason, models) => {
6791
6910
  if (stopped2 || !approved(deviceId)) return;
6792
- push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {} });
6911
+ push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {}, ...models ? { models } : {} });
6793
6912
  };
6794
6913
  const needsAssertion = (msg, s) => {
6795
6914
  if (msg.type === "session.decide") return msg.decision !== "deny" && riskOf2(s)?.risk !== "low";
@@ -6851,6 +6970,9 @@ function createPhoneGateway(deps) {
6851
6970
  return s.pending.requestId === msg.requestId ? void 0 : { status: "rejected", reason: "stale" };
6852
6971
  case "session.interrupt":
6853
6972
  return s.tmuxSession || backendOf(s) === "omp-rpc" ? void 0 : { status: "rejected", reason: "read_only" };
6973
+ case "session.model":
6974
+ if (canSwitch(s)) return void 0;
6975
+ return { status: "rejected", reason: s.state === "exited" ? "ended" : "not_launched" };
6854
6976
  }
6855
6977
  };
6856
6978
  const local = (msg) => {
@@ -6863,6 +6985,8 @@ function createPhoneGateway(deps) {
6863
6985
  return { type: "session.answer", id: msg.id, requestId: msg.requestId, answers: msg.answers, ...msg.cancel ? { cancel: true } : {} };
6864
6986
  case "session.interrupt":
6865
6987
  return { type: "session.interrupt", id: msg.id };
6988
+ case "session.model":
6989
+ return { type: "session.model", id: msg.id, model: msg.model, effort: msg.effort };
6866
6990
  }
6867
6991
  };
6868
6992
  const outcomeOf = (code) => {
@@ -6984,6 +7108,41 @@ function createPhoneGateway(deps) {
6984
7108
  });
6985
7109
  return outcome ?? opts.silent ?? { status: "applied" };
6986
7110
  };
7111
+ const modelChange = async (out) => {
7112
+ let when;
7113
+ let refusal2;
7114
+ await deps.dispatch(out, (m) => {
7115
+ if (m.type === "session.model.accepted" && m.id === out.id) when ??= m.when;
7116
+ if (m.type === "error" && m.request === "session.model" && (m.id === void 0 || m.id === out.id)) refusal2 ??= { code: m.code };
7117
+ });
7118
+ if (when) modelFailures.delete(out.id);
7119
+ if (when) return when === "turn" ? { status: "applied", reason: "scheduled" } : { status: "applied" };
7120
+ const model = ModelRefusalSchema.safeParse(refusal2?.code);
7121
+ if (model.success) return { status: "rejected", reason: model.data };
7122
+ const known2 = RefusalCodeSchema.safeParse(refusal2?.code);
7123
+ return known2.success ? { status: "rejected", reason: known2.data } : { status: "failed" };
7124
+ };
7125
+ const modelsList = async (deviceId, tool) => {
7126
+ const requestId = randomUUID5();
7127
+ let listing;
7128
+ let timer;
7129
+ try {
7130
+ await Promise.race([
7131
+ deps.dispatch({ type: "models.list", requestId, tool }, (m) => {
7132
+ if (m.type === "models.listing" && m.requestId === requestId) listing ??= m;
7133
+ }),
7134
+ new Promise((r) => {
7135
+ timer = setTimeout(r, MODELS_WAIT_MS);
7136
+ })
7137
+ ]);
7138
+ } catch (e) {
7139
+ log2(`phone models.list failed (${e instanceof Error ? e.name : "error"})`);
7140
+ } finally {
7141
+ clearTimeout(timer);
7142
+ }
7143
+ if (!listing) return push(deviceId, { type: "models.listing", tool, models: [], error: true });
7144
+ push(deviceId, { type: "models.listing", tool, models: toPhoneModels(listing.models), ...listing.error !== void 0 ? { error: true } : {} });
7145
+ };
6987
7146
  const chatTeam = async (deviceId, msg) => {
6988
7147
  const turn = ownTurn(deviceId, msg.qid);
6989
7148
  if (!turn?.team || !deps.teams) return { status: "rejected", reason: "unknown_session" };
@@ -7019,7 +7178,23 @@ function createPhoneGateway(deps) {
7019
7178
  push: (deviceId, m) => push(deviceId, m),
7020
7179
  log: log2
7021
7180
  }) : void 0;
7181
+ const pcOperation = (deviceId, msg) => {
7182
+ const pc = deps.pc;
7183
+ const update = msg.type === "daemon.update";
7184
+ if (!pc || !(update ? pc.canUpdate : pc.canRestart)) return { status: "rejected", reason: "unavailable" };
7185
+ if (update && (!SEMVER.test(deps.version) || compareVersions(msg.version, deps.version) <= 0)) return { status: "rejected", reason: "invalid" };
7186
+ const refused = checkAssertion(deviceId, msg.assertion, "pc", update ? pcSubject("update", msg.version) : pcSubject("restart"));
7187
+ if (refused) return refused;
7188
+ const reply = (m) => {
7189
+ if (m.type === "update.failed") push(deviceId, { type: "daemon.failed", version: m.version, reason: m.reason });
7190
+ };
7191
+ log2(`phone: device ${deviceId} asked the daemon to ${update ? `update to ${msg.version}` : "restart"}`);
7192
+ if (update) pc.update(msg.version, reply);
7193
+ else pc.restart(reply);
7194
+ return { status: "applied" };
7195
+ };
7022
7196
  const run2 = async (deviceId, msg) => {
7197
+ if (msg.type === "daemon.update" || msg.type === "daemon.restart") return pcOperation(deviceId, msg);
7023
7198
  if (isTeamsOperation(msg)) return teamsOps ? teamsOps.run(deviceId, msg) : { status: "rejected", reason: "unavailable" };
7024
7199
  if (msg.type === "chat.ignore") return chatIgnore(deviceId, msg);
7025
7200
  if (msg.type === "chat.team") return chatTeam(deviceId, msg);
@@ -7048,6 +7223,7 @@ function createPhoneGateway(deps) {
7048
7223
  const refused = precheck(deviceId, msg);
7049
7224
  if (refused) return refused;
7050
7225
  const out = local(msg);
7226
+ if (out.type === "session.model") return modelChange(out);
7051
7227
  let error;
7052
7228
  await deps.dispatch(out, (m) => {
7053
7229
  if (m.type === "error" && (m.request === void 0 || m.request === out.type)) error ??= { code: m.code };
@@ -7057,7 +7233,7 @@ function createPhoneGateway(deps) {
7057
7233
  const write = async (deviceId, msg) => {
7058
7234
  const begun = registry.begin(deviceId, msg.op, fingerprint(msg));
7059
7235
  if (begun.kind === "conflict") return result(deviceId, msg.op, "rejected", "conflict");
7060
- if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason);
7236
+ if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason, begun.record.models);
7061
7237
  let outcome;
7062
7238
  try {
7063
7239
  result(deviceId, msg.op, "received");
@@ -7066,8 +7242,8 @@ function createPhoneGateway(deps) {
7066
7242
  log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`);
7067
7243
  outcome = { status: "failed" };
7068
7244
  }
7069
- registry.finish(msg.op, outcome.status, outcome.reason);
7070
- result(deviceId, msg.op, outcome.status, outcome.reason);
7245
+ registry.finish(msg.op, outcome.status, outcome.reason, outcome.models);
7246
+ result(deviceId, msg.op, outcome.status, outcome.reason, outcome.models);
7071
7247
  if (msg.type === "credential.register" && outcome.status === "applied" && approved(deviceId)) push(deviceId, snapshot(deviceId), "snapshot");
7072
7248
  if (msg.type === "device.forget" && outcome.status === "applied") {
7073
7249
  let timer;
@@ -7154,13 +7330,19 @@ function createPhoneGateway(deps) {
7154
7330
  case "presence":
7155
7331
  follower(deviceId).visible = msg.visible;
7156
7332
  return;
7333
+ case "models.list":
7334
+ if (!deps.models) return;
7335
+ void modelsList(deviceId, msg.tool).catch((e) => log2(`phone models.list failed (${e instanceof Error ? e.name : "error"})`));
7336
+ return;
7157
7337
  case "snapshot.request":
7158
7338
  follower(deviceId).subscribed = true;
7159
7339
  push(deviceId, snapshot(deviceId), "snapshot");
7340
+ replayModelFailures(deviceId);
7160
7341
  return teamsAndTasks(deviceId);
7161
7342
  case "session.thread": {
7162
7343
  if (!live3(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
7163
- return push(deviceId, { type: "session.thread", id: msg.id, ...toPhoneThread(deps.thread(msg.id) ?? []) });
7344
+ push(deviceId, { type: "session.thread", id: msg.id, ...toPhoneThread(deps.thread(msg.id) ?? []) });
7345
+ return push(deviceId, { type: "session.checklist", id: msg.id, checklist: checklistOf(msg.id).list }, `session.checklist:${msg.id}`);
7164
7346
  }
7165
7347
  case "session.pending": {
7166
7348
  const session = live3(msg.id);
@@ -7223,6 +7405,15 @@ function createPhoneGateway(deps) {
7223
7405
  const challenge = deps.challenges.issue({ deviceId, purpose: msg.purpose, subject: msg.digest });
7224
7406
  return push(deviceId, { type: "confirm.challenge", purpose: msg.purpose, challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS, digest: msg.digest });
7225
7407
  }
7408
+ if (msg.purpose === "pc") {
7409
+ const pc = deps.pc;
7410
+ if (!pc || !msg.action || msg.action === "update" !== (msg.version !== void 0)) return invalid2();
7411
+ if (!(msg.action === "update" ? pc.canUpdate : pc.canRestart)) return invalid2();
7412
+ const cred = deps.credential(deviceId);
7413
+ if (!cred || cred.rpId !== rp.rpId) return invalid2();
7414
+ const challenge = deps.challenges.issue({ deviceId, purpose: "pc", subject: pcSubject(msg.action, msg.version) });
7415
+ return push(deviceId, { type: "confirm.challenge", purpose: "pc", challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
7416
+ }
7226
7417
  const unhandled = msg.purpose;
7227
7418
  void unhandled;
7228
7419
  return invalid2();
@@ -7252,6 +7443,7 @@ function createPhoneGateway(deps) {
7252
7443
  const request = typeof type === "string" && REQUEST_NAME.test(type) ? type : void 0;
7253
7444
  log2(`phone message refused: ${request ?? "?"}`);
7254
7445
  const op = OperationIdSchema.safeParse(raw?.op);
7446
+ if (request === "session.model" && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
7255
7447
  if ((request !== void 0 && TEAMS_OPERATION_TYPES.includes(request) || request === "push.subscribe" || request === "chat.launch" || request === "chat.close" || request === "chat.act" || request === "chat.ignore" || request === "chat.team" || request === "chat.task" || request === "team.resume" || request === "task.seen") && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
7256
7448
  return push(deviceId, { type: "error", code: "invalid", ...request !== void 0 ? { request } : {} });
7257
7449
  }
@@ -7285,17 +7477,40 @@ function createPhoneGateway(deps) {
7285
7477
  }
7286
7478
  };
7287
7479
  const removed = (id) => {
7480
+ checklists.delete(id);
7288
7481
  for (const [deviceId, f] of followers) {
7289
7482
  f.reads.delete(id);
7290
7483
  if (f.watching === id) f.watching = null;
7291
7484
  if (f.subscribed) push(deviceId, { type: "session.removed", id });
7292
7485
  }
7293
7486
  };
7487
+ const checklistChanged = (id, item) => {
7488
+ if (item.kind !== "tool" || item.helperId !== void 0 || !TODO_TOOLS.has(item.tool) || !checklists.has(id)) return;
7489
+ const before = checklists.get(id);
7490
+ const now = computeChecklist(id);
7491
+ checklists.set(id, now);
7492
+ if (JSON.stringify(now.list) !== JSON.stringify(before.list)) {
7493
+ for (const [deviceId, f] of followers) if (f.watching === id) push(deviceId, { type: "session.checklist", id, checklist: now.list }, `session.checklist:${id}`);
7494
+ }
7495
+ const s = live3(id);
7496
+ if (s && JSON.stringify(now.summary) !== JSON.stringify(before.summary)) {
7497
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "session.updated", session: projected(deviceId, s) }, `session.updated:${id}`);
7498
+ }
7499
+ };
7294
7500
  const unsubscribeMessage = mobile.onMessage(receive);
7295
7501
  const unsubscribeChange = mobile.onChange(pruneRevoked);
7296
7502
  return {
7297
7503
  forward(m) {
7298
- if (stopped2 || followers.size === 0) return;
7504
+ if (!stopped2) try {
7505
+ keepModelFailure(m);
7506
+ } catch (e) {
7507
+ log2(`phone model failure of ${m.type} failed (${e instanceof Error ? e.name : "error"})`);
7508
+ }
7509
+ if (stopped2) return;
7510
+ if (followers.size === 0) {
7511
+ checklists.clear();
7512
+ return;
7513
+ }
7299
7514
  try {
7300
7515
  switch (m.type) {
7301
7516
  case "session.updated":
@@ -7306,6 +7521,12 @@ function createPhoneGateway(deps) {
7306
7521
  return;
7307
7522
  case "session.removed":
7308
7523
  return removed(m.id);
7524
+ case "error": {
7525
+ const reason = ModelRefusalSchema.safeParse(m.code);
7526
+ if (m.request !== "session.model" || m.id === void 0 || !reason.success || !live3(m.id)) return;
7527
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "session.model.failed", id: m.id, reason: reason.data });
7528
+ return;
7529
+ }
7309
7530
  case "context.prefs":
7310
7531
  for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, snapshot(deviceId), "snapshot");
7311
7532
  return;
@@ -7324,12 +7545,20 @@ function createPhoneGateway(deps) {
7324
7545
  }
7325
7546
  return;
7326
7547
  }
7548
+ // The daemon updating or restarting itself, whoever asked. Never npm's output nor a command: both hold the PC's paths.
7549
+ case "update.progress":
7550
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "daemon.progress", version: m.version, step: m.step });
7551
+ return;
7552
+ case "update.failed":
7553
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "daemon.failed", version: m.version, reason: m.reason });
7554
+ return;
7327
7555
  case "session.preview":
7328
7556
  for (const [deviceId, f] of followers) {
7329
7557
  if (f.watching === m.id) push(deviceId, { type: "session.preview", id: m.id, lines: [...m.lines] }, `session.preview:${m.id}`);
7330
7558
  }
7331
7559
  return;
7332
7560
  case "session.thread.item": {
7561
+ checklistChanged(m.id, m.item);
7333
7562
  const watchers = [...followers].filter(([, f]) => f.watching === m.id);
7334
7563
  if (watchers.length === 0) return;
7335
7564
  const { item, cut: cut2 } = toPhoneItem(m.item);
@@ -8110,6 +8339,7 @@ async function startServer(opts) {
8110
8339
  async function applyOmpModel(s) {
8111
8340
  const next2 = s.modelNext;
8112
8341
  typing.add(s.id);
8342
+ applyingModel.set(s.id, null);
8113
8343
  try {
8114
8344
  await omp.setModel(s.id, next2);
8115
8345
  log(`model of ${s.id} is now ${next2.model ?? "unchanged"}${next2.effort ? ` \xB7 ${next2.effort}` : ""}`);
@@ -8117,32 +8347,91 @@ async function startServer(opts) {
8117
8347
  const message = e.message;
8118
8348
  log(`model change of ${s.id} failed: ${message}`);
8119
8349
  store.patch(s.id, { modelNext: void 0 });
8120
- broadcast({ type: "error", id: s.id, message, request: "session.model" });
8350
+ modelFailed(s.id, message, "refused");
8121
8351
  } finally {
8122
8352
  typing.delete(s.id);
8353
+ afterModelApply(s.id);
8354
+ }
8355
+ }
8356
+ const modelFailed = (id, message, code) => broadcast({ type: "error", id, message, request: "session.model", code });
8357
+ const applyingModel = /* @__PURE__ */ new Map();
8358
+ function afterModelApply(id) {
8359
+ const later = applyingModel.get(id);
8360
+ applyingModel.delete(id);
8361
+ const s = store.get(id);
8362
+ if (!later || !s) return;
8363
+ const refusal2 = modelRefusal(s, later.effort ?? null);
8364
+ if (refusal2) {
8365
+ store.patch(id, { modelNext: void 0 });
8366
+ return modelFailed(id, refusal2.message, refusal2.refused);
8123
8367
  }
8368
+ store.patch(id, { modelNext: later.model === s.model && later.effort === s.effort ? void 0 : later });
8369
+ }
8370
+ function canApplyNow(id) {
8371
+ return free(store.get(id)) && !typing.has(id) && !closing.has(id) && !cardThreads.queuedRelays().some((q) => q.relay.id === id);
8372
+ }
8373
+ function modelRefusal(s, effort) {
8374
+ const inPlace = backendOf(s) === "omp-rpc";
8375
+ if (s.source === "hook_only") return { refused: "not_launched", message: READ_ONLY2 };
8376
+ if (s.state === "exited") return { refused: "ended", message: "the session has ended" };
8377
+ if (!resumeKey(s)) return { refused: "no_conversation", message: "no conversation to resume" };
8378
+ if (inPlace && !omp.isLive(s.id)) return { refused: "not_running", message: "the session is not running" };
8379
+ if (effort && !inPlace && !EFFORT_ARGS[s.tool]) return { refused: "effort_in_model", message: `${s.tool} takes its effort in the model id` };
8380
+ return null;
8381
+ }
8382
+ async function changeModel(id, model, effort) {
8383
+ const s = store.get(id);
8384
+ if (!s) throw unknownSession();
8385
+ const refusal2 = modelRefusal(s, effort);
8386
+ if (refusal2) return refusal2;
8387
+ const next2 = { ...model ? { model } : {}, ...effort ? { effort } : {} };
8388
+ if (applyingModel.has(id)) {
8389
+ applyingModel.set(id, next2);
8390
+ store.patch(id, { modelNext: next2 });
8391
+ return { when: "turn" };
8392
+ }
8393
+ if (next2.model === s.model && next2.effort === s.effort) {
8394
+ if (s.modelNext) store.patch(id, { modelNext: void 0 });
8395
+ return { when: "none" };
8396
+ }
8397
+ const when = canApplyNow(id) ? "now" : "turn";
8398
+ store.patch(id, { modelNext: next2 });
8399
+ return { when };
8124
8400
  }
8125
8401
  async function applyModelNext(id) {
8126
8402
  const s = store.get(id);
8127
- if (!s?.modelNext || !free(s) || typing.has(id) || closing.has(id) || cardThreads.queuedRelays().some((q) => q.relay.id === id)) return;
8403
+ if (!s?.modelNext || !canApplyNow(id)) return;
8128
8404
  if (backendOf(s) === "omp-rpc") return applyOmpModel(s);
8129
- const key = resumeKey(s);
8130
- if (!key || s.source !== "launcher") {
8405
+ const { model, effort } = s.modelNext;
8406
+ const refusal2 = modelRefusal(s, effort ?? null);
8407
+ if (refusal2) {
8131
8408
  store.patch(id, { modelNext: void 0 });
8132
- return;
8409
+ return modelFailed(id, refusal2.message, refusal2.refused);
8133
8410
  }
8134
- const { model, effort } = s.modelNext;
8411
+ const key = resumeKey(s);
8135
8412
  typing.add(id);
8413
+ applyingModel.set(id, null);
8136
8414
  try {
8137
8415
  for (const [k, at] of respawned) if (Date.now() - at > RESPAWN_HOOKS_MS) respawned.delete(k);
8138
8416
  respawned.set(id, Date.now());
8139
8417
  await endSession(s);
8140
8418
  const ended = store.get(id);
8141
- if (!ended || ended.archivedAt !== void 0) return;
8419
+ if (!ended || ended.archivedAt !== void 0) {
8420
+ store.patch(id, { modelNext: void 0 });
8421
+ return;
8422
+ }
8142
8423
  const refused = await respawn({ ...ended, modelNext: void 0 }, key, model, effort);
8143
- if (refused) log(`model change of ${id} failed: ${refused}`);
8424
+ if (refused) {
8425
+ log(`model change of ${id} failed: ${refused}`);
8426
+ modelFailed(id, refused, "restart_failed");
8427
+ }
8428
+ } catch (e) {
8429
+ log(`model change of ${id} failed: ${e.message}`);
8430
+ store.patch(id, { modelNext: void 0 });
8431
+ modelFailed(id, e.message, "restart_failed");
8144
8432
  } finally {
8145
8433
  typing.delete(id);
8434
+ afterModelApply(id);
8146
8435
  }
8147
8436
  }
8148
8437
  const chatLaunches = /* @__PURE__ */ new Set();
@@ -8405,6 +8694,26 @@ ${now2.prompt}` });
8405
8694
  for (const id of leaverRefused) if (!leaving.has(id)) leaverRefused.delete(id);
8406
8695
  }
8407
8696
  const seatsInFlight = /* @__PURE__ */ new Set();
8697
+ async function seatModels(before, team) {
8698
+ const out = [];
8699
+ const roles = listDefinitions(team.cwd).roles;
8700
+ for (const seat of team.seats) {
8701
+ const was = before.get(seat.role);
8702
+ if (!was || seat.leaving || was.tool !== seat.tool || was.model === seat.model && was.effort === seat.effort) continue;
8703
+ const holder = seat.sessionId ? store.get(seat.sessionId) : void 0;
8704
+ if (!holder) continue;
8705
+ const resolved = resolveSeats([seat], roles);
8706
+ const want = "unknown" in resolved ? seat : resolved[0];
8707
+ try {
8708
+ const r = await changeModel(holder.id, want.model ?? null, want.effort ?? null);
8709
+ if ("refused" in r) out.push({ role: seat.role, session: holder.id, code: r.refused });
8710
+ else if (r.when !== "none") out.push({ role: seat.role, session: holder.id, when: r.when });
8711
+ } catch (e) {
8712
+ log(`team ${team.id}: model of @${seat.role} (${holder.id}) not changed: ${e.message}`);
8713
+ }
8714
+ }
8715
+ return out;
8716
+ }
8408
8717
  async function composeTeam(msg) {
8409
8718
  const changed = (extra) => ({ type: "team.changed", rid: msg.rid, ...extra });
8410
8719
  const t = teams.get(msg.team);
@@ -8421,12 +8730,14 @@ ${now2.prompt}` });
8421
8730
  if (busy) return changed({ reason: `@${busy.role} : une op\xE9ration est d\xE9j\xE0 en cours sur ce si\xE8ge` });
8422
8731
  for (const k of keys) seatsInFlight.add(k);
8423
8732
  try {
8733
+ const before = new Map(t.seats.map((s) => [s.role, s]));
8424
8734
  const r = teams.compose(msg.team, msg.seats);
8425
8735
  if (typeof r === "string") return changed({ reason: r });
8426
8736
  log(`team ${t.id} composed: +${r.added.join(",") || "-"} \u2212${r.removed.join(",") || "-"}`);
8427
8737
  const failed = r.added.length ? await startSeats(r.team, msg.seats.filter((s) => r.added.includes(s.role))) : null;
8428
8738
  closeLeavers();
8429
- return changed({ team: teams.get(t.id), ...failed ? { reason: failed } : {} });
8739
+ const models2 = await seatModels(before, teams.get(t.id));
8740
+ return changed({ team: teams.get(t.id), ...failed ? { reason: failed } : {}, ...models2.length ? { models: models2 } : {} });
8430
8741
  } finally {
8431
8742
  for (const k of keys) seatsInFlight.delete(k);
8432
8743
  }
@@ -8767,21 +9078,10 @@ ${now2.prompt}` });
8767
9078
  return;
8768
9079
  }
8769
9080
  case "session.model": {
8770
- const s = store.get(msg.id);
8771
- if (!s) return refuse("unknown session", msg.id);
8772
- if (s.source === "hook_only") return refuse(READ_ONLY2, msg.id);
8773
- if (s.state === "exited") return refuse("the session has ended", msg.id);
8774
- if (!resumeKey(s)) return refuse("no conversation to resume", msg.id);
8775
- if (backendOf(s) === "omp-rpc" && !omp.isLive(s.id)) return refuse("the session is not running", msg.id);
8776
- if (msg.effort && backendOf(s) !== "omp-rpc" && !EFFORT_ARGS[s.tool]) return refuse(`${s.tool} takes its effort in the model id`, msg.id);
8777
- const next2 = { ...msg.model ? { model: msg.model } : {}, ...msg.effort ? { effort: msg.effort } : {} };
8778
- if (next2.model === s.model && next2.effort === s.effort) {
8779
- if (s.modelNext) store.patch(msg.id, { modelNext: void 0 });
8780
- return;
8781
- }
8782
- store.patch(msg.id, { modelNext: next2 });
8783
- await applyModelNext(msg.id);
8784
- return;
9081
+ if (!store.get(msg.id)) return refuse("unknown session", msg.id);
9082
+ const r = await changeModel(msg.id, msg.model, msg.effort);
9083
+ if ("refused" in r) return refuse({ message: r.message, code: r.refused }, msg.id);
9084
+ return reply({ type: "session.model.accepted", id: msg.id, when: r.when });
8785
9085
  }
8786
9086
  case "session.delete": {
8787
9087
  const s = store.get(msg.id);
@@ -9047,7 +9347,7 @@ ${now2.prompt}` });
9047
9347
  },
9048
9348
  onChatEnd: (deviceId, end) => notifyChatEnd?.(deviceId, end.code),
9049
9349
  version: opts.daemon?.version ?? "unknown",
9050
- boot: randomUUID5(),
9350
+ boot: randomUUID6(),
9051
9351
  log,
9052
9352
  now,
9053
9353
  rp: () => rpOf(mobile.state().relayUrl),
@@ -9060,7 +9360,22 @@ ${now2.prompt}` });
9060
9360
  projectName: (id) => id ? projects.projects.find((p) => p.id === id)?.name ?? null : null,
9061
9361
  // A project's folder is its first local repo: only a project with one can be named by the phone.
9062
9362
  projectByName: (name) => projects.projects.find((p) => p.name === name && p.repos.some((r) => r.machine === "local"))?.id ?? null,
9063
- teamCatalog: true
9363
+ teamCatalog: true,
9364
+ // The phone's model switch (#58): `session.model` answers with a code, and `models.list` asks this machine's agents.
9365
+ models: true,
9366
+ // The phone's « Mettre à jour le PC » and « Redémarrer le daemon »: the updater Desktop's `update.daemon` uses, progress broadcast.
9367
+ ...opts.updater && opts.daemon ? {
9368
+ pc: {
9369
+ canUpdate: opts.daemon.install === "npm" && opts.daemon.systemd,
9370
+ canRestart: opts.daemon.systemd,
9371
+ update: (version, reply) => {
9372
+ void opts.updater.update(version, broadcast, reply);
9373
+ },
9374
+ restart: (reply) => {
9375
+ void opts.updater.restart(broadcast, reply);
9376
+ }
9377
+ }
9378
+ } : {}
9064
9379
  });
9065
9380
  let unsubscribePush;
9066
9381
  if (mobile) {