@paradow/ledge 0.27.0 → 0.29.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-7SJJTIEA.js → action-MAPPYLUM.js} +4 -4
  2. package/dist/cli/{attach-V7UPHVB2.js → attach-OC64CLB2.js} +8 -8
  3. package/dist/cli/{bridge-JK3PMG7T.js → bridge-TWX4V37E.js} +1 -1
  4. package/dist/cli/{chunk-IGAGJVRZ.js → chunk-353WRRGG.js} +3 -3
  5. package/dist/cli/{chunk-UPKJO3BZ.js → chunk-73YNZ3BN.js} +1 -1
  6. package/dist/cli/{chunk-37G5F75H.js → chunk-7S7ZLGIY.js} +150 -13
  7. package/dist/cli/{chunk-TOSPL5H2.js → chunk-ETGOWBUL.js} +3 -3
  8. package/dist/cli/chunk-JZEWCO6X.js +6 -0
  9. package/dist/cli/{chunk-ADAGKOFO.js → chunk-K3SDHHZF.js} +1 -1
  10. package/dist/cli/{chunk-RA3OIXEQ.js → chunk-MZVMW356.js} +2 -2
  11. package/dist/cli/{chunk-7GIUG73R.js → chunk-RFFTWAAF.js} +1 -1
  12. package/dist/cli/{chunk-7OGFH6KJ.js → chunk-TNYBWWRO.js} +2 -2
  13. package/dist/cli/{chunk-CVHX7GUB.js → chunk-TREGH6WM.js} +353 -49
  14. package/dist/cli/{chunk-7JRQCKBP.js → chunk-UWDE365N.js} +2 -2
  15. package/dist/cli/{chunk-YLN3VOGV.js → chunk-WCBXS3EU.js} +2 -2
  16. package/dist/cli/{chunk-ISNHG5SJ.js → chunk-WIUJNSAZ.js} +5 -5
  17. package/dist/cli/{chunk-2N7PSP7C.js → chunk-XZYQMKKO.js} +3 -3
  18. package/dist/cli/{daemon-YUPB5NUG.js → daemon-ZTZ7VMYA.js} +7 -7
  19. package/dist/cli/{decide-HFI2WQXI.js → decide-4BIYS3Y2.js} +3 -3
  20. package/dist/cli/{handover-GKORMXZ2.js → handover-IS7BJYGZ.js} +4 -4
  21. package/dist/cli/{hook-TJJEFV54.js → hook-3VKVD4IP.js} +9 -9
  22. package/dist/cli/index.js +16 -16
  23. package/dist/cli/{launch-QKQZZSIM.js → launch-XAPN2M3Q.js} +8 -8
  24. package/dist/cli/{send-RI7JOO5K.js → send-ZLF2ZXMJ.js} +4 -4
  25. package/dist/cli/{status-RQRS26S2.js → status-NONO6E2S.js} +2 -2
  26. package/dist/cli/{task-U2XHBUKW.js → task-47MVWDCY.js} +4 -4
  27. package/dist/cli/{team-NFC4XEIA.js → team-EJ4YIXJH.js} +6 -6
  28. package/dist/cli/{tui-UWR7SDYF.js → tui-CAP4O4YQ.js} +8 -8
  29. package/dist/cli/{version-YYNMMWKJ.js → version-EHBPJO7K.js} +1 -1
  30. package/dist/cli/{who-WW6IL4KD.js → who-FHAXYCM2.js} +3 -3
  31. package/package.json +1 -1
  32. package/dist/cli/chunk-HMTOPOJG.js +0 -6
@@ -10,7 +10,7 @@ import {
10
10
  templateExists,
11
11
  templateId,
12
12
  templateText
13
- } from "./chunk-YLN3VOGV.js";
13
+ } from "./chunk-WCBXS3EU.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-7JRQCKBP.js";
42
+ } from "./chunk-UWDE365N.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-UPKJO3BZ.js";
50
+ } from "./chunk-73YNZ3BN.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-ADAGKOFO.js";
60
+ } from "./chunk-K3SDHHZF.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,
@@ -103,6 +107,7 @@ import {
103
107
  THREAD_BUDGET,
104
108
  THREAD_ITEMS_MAX,
105
109
  THREAD_TEXT_MAX,
110
+ TODO_TOOLS,
106
111
  TOOL_TEXT_MAX,
107
112
  TOPIC_MAX,
108
113
  TeamSchema,
@@ -114,6 +119,7 @@ import {
114
119
  backendOf,
115
120
  canonicalJson,
116
121
  contextNear,
122
+ doneCount,
117
123
  ensureDaemonToken,
118
124
  extractProposal,
119
125
  givenByLedge,
@@ -121,6 +127,7 @@ import {
121
127
  isArchived,
122
128
  isTeamLive,
123
129
  isTeamsOperation,
130
+ latestTodos,
124
131
  offers,
125
132
  ompApprovalFor,
126
133
  padPlain,
@@ -147,7 +154,7 @@ import {
147
154
  teamsPurpose,
148
155
  tokenEquals,
149
156
  transmissions
150
- } from "./chunk-37G5F75H.js";
157
+ } from "./chunk-7S7ZLGIY.js";
151
158
  import {
152
159
  log
153
160
  } from "./chunk-6IYPRUQG.js";
@@ -2241,7 +2248,7 @@ function teamsJournal(path) {
2241
2248
 
2242
2249
  // src/daemon/server.ts
2243
2250
  import { createServer } from "http";
2244
- import { randomUUID as randomUUID5 } from "crypto";
2251
+ import { randomUUID as randomUUID6 } from "crypto";
2245
2252
  import { homedir as homedir7 } from "os";
2246
2253
  import { basename as basename6, join as join18, resolve as resolve8 } from "path";
2247
2254
  import { existsSync } from "fs";
@@ -5675,6 +5682,7 @@ function serveSsh(ws, alias, opts = {}) {
5675
5682
 
5676
5683
  // src/mobile/gateway.ts
5677
5684
  import { hostname } from "os";
5685
+ import { randomUUID as randomUUID5 } from "crypto";
5678
5686
 
5679
5687
  // ../ui/src/semver.ts
5680
5688
  var parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -6025,6 +6033,8 @@ function fingerprint(m) {
6025
6033
  f.rows ?? null,
6026
6034
  ...f.row !== void 0 || f.team !== void 0 || f.task !== void 0 ? [f.row ?? null, f.team ?? null, f.task ?? null] : [],
6027
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] : [],
6028
6038
  // A role or team operation is its whole content: the same id with another prompt, seats or base is another operation.
6029
6039
  ...isTeamsOperation(m) ? [teamsContent(m)] : []
6030
6040
  ]);
@@ -6052,12 +6062,16 @@ var OperationRegistry = class {
6052
6062
  return { kind: "fresh", record: { ...record } };
6053
6063
  }
6054
6064
  /** An operation already forgotten (expired) is left forgotten. */
6055
- finish(op, status, reason) {
6065
+ finish(op, status, reason, models, how) {
6056
6066
  const r = this.records.get(op);
6057
6067
  if (!r) return;
6058
6068
  r.status = status;
6059
6069
  if (reason) r.reason = reason;
6060
6070
  else delete r.reason;
6071
+ if (models) r.models = models;
6072
+ else delete r.models;
6073
+ if (how) r.how = how;
6074
+ else delete r.how;
6061
6075
  }
6062
6076
  get(op) {
6063
6077
  this.expire();
@@ -6209,7 +6223,27 @@ function toPhoneRequest(s, bodyMax) {
6209
6223
  return toPhonePending(s.pending, pendingKey(s.pending), verdict.risk, verdict.reason, bodyMax);
6210
6224
  }
6211
6225
  var toPhoneFullRequest = (s) => toPhoneRequest(s, PENDING_BODY_MAX);
6212
- function toPhoneSession(s, readAt2) {
6226
+ var checklistCut = (todos) => todos.length > CHECKLIST_TASKS_MAX || todos.some((t) => t.text.length > CHECKLIST_TEXT_MAX);
6227
+ function toPhoneChecklist(todos) {
6228
+ if (!todos) return null;
6229
+ return {
6230
+ tasks: todos.slice(0, CHECKLIST_TASKS_MAX).map((t) => ({ text: clip(t.text, CHECKLIST_TEXT_MAX).text, status: t.status })),
6231
+ done: doneCount(todos),
6232
+ total: todos.length,
6233
+ ...checklistCut(todos) ? { cut: true } : {}
6234
+ };
6235
+ }
6236
+ function checklistSummary(todos) {
6237
+ if (!todos) return void 0;
6238
+ const current2 = todos.find((t) => t.status === "in_progress");
6239
+ return {
6240
+ done: doneCount(todos),
6241
+ total: todos.length,
6242
+ ...current2 ? { current: clip(current2.text, CHECKLIST_TEXT_MAX).text } : {},
6243
+ ...checklistCut(todos) ? { cut: true } : {}
6244
+ };
6245
+ }
6246
+ function toPhoneSession(s, readAt2, checklist) {
6213
6247
  const pending = toPhoneRequest(s, SNAPSHOT_BODY_MAX);
6214
6248
  return {
6215
6249
  id: s.id,
@@ -6222,13 +6256,24 @@ function toPhoneSession(s, readAt2) {
6222
6256
  lastActivityAt: s.lastActivityAt,
6223
6257
  // Topics are already cut to TOPIC_MAX where they are set; clipped again so no source can swell a snapshot.
6224
6258
  ...s.topic !== void 0 ? { topic: clip(s.topic, TOPIC_MAX).text } : {},
6225
- ...pickSet(s, ["repo", "branch", "worktree", "role", "roleLabel", "team", "context", "relieves"]),
6259
+ ...pickSet(s, ["repo", "branch", "worktree", "role", "roleLabel", "team", "context", "relieves", "exitedAt"]),
6260
+ // Known up front: the phone shows neither Close nor Resume for it, rather than learning it from a refusal.
6261
+ ...s.source === "hook_only" ? { readOnly: true } : {},
6226
6262
  // Its relief's state only (context handover spec §3.2): the phone shows it, the PC starts it.
6227
6263
  ...s.handover ? { handover: { state: s.handover.state, ...s.handover.toName ? { toName: s.handover.toName.slice(0, 80) } : {} } } : {},
6264
+ // Its model, effort and the change waiting for its next free moment (#58), cut to the bounds of a model id and an effort.
6265
+ ...s.model !== void 0 ? { model: clip(s.model, MODEL_MAX).text } : {},
6266
+ ...s.effort !== void 0 ? { effort: clip(s.effort, EFFORT_MAX).text } : {},
6267
+ ...s.modelNext ? { modelNext: {
6268
+ ...s.modelNext.model !== void 0 ? { model: clip(s.modelNext.model, MODEL_MAX).text } : {},
6269
+ ...s.modelNext.effort !== void 0 ? { effort: clip(s.modelNext.effort, EFFORT_MAX).text } : {}
6270
+ } } : {},
6271
+ ...canSwitch(s) ? { canSwitch: true } : {},
6228
6272
  helpers: s.helpers.map((h) => ({ id: h.id, kind: h.kind, task: h.task, state: h.state, ...pickSet(h, ["finishedAt"]) })),
6229
6273
  // A preview only: the whole reply is in the thread.
6230
6274
  ...s.lastReply ? { lastReply: { text: clip(s.lastReply.text, TOOL_TEXT_MAX).text, at: s.lastReply.at, ...pickSet(s.lastReply, ["plan"]) } } : {},
6231
6275
  ...pending ? { pending } : {},
6276
+ ...checklist ? { checklist } : {},
6232
6277
  unread: !!s.lastReply && s.lastReply.at > readAt2
6233
6278
  };
6234
6279
  }
@@ -6449,10 +6494,39 @@ function toPhoneTask(x, owner, teamName) {
6449
6494
  };
6450
6495
  }
6451
6496
  var phoneTasks = (tasks, now) => tasks.filter((x) => x.giver === LEDGE_GIVER && (!taskClosed(x) || !x.seen && now - Date.parse(x.updatedAt) < 864e5)).slice(-200);
6497
+ var MODEL_MAX = 200;
6498
+ var EFFORT_MAX = 20;
6499
+ var MODELS_MAX = 40;
6500
+ function canSwitch(s) {
6501
+ return s.source === "launcher" && s.state !== "exited";
6502
+ }
6503
+ function toPhoneModels(models) {
6504
+ const out = [];
6505
+ for (const m of models) {
6506
+ if (out.length >= MODELS_MAX) break;
6507
+ const efforts = m.efforts?.filter((e) => EffortSchema.safeParse(e).success).slice(0, 8);
6508
+ const c = PhoneModelChoiceSchema.safeParse({
6509
+ id: m.id,
6510
+ label: clip(m.label, 80).text,
6511
+ ...m.description !== void 0 ? { description: clip(m.description, 200).text } : {},
6512
+ ...efforts ? { efforts } : {},
6513
+ ...pickSet(m, ["defaultEffort", "isDefault"])
6514
+ });
6515
+ if (c.success) out.push(c.data);
6516
+ }
6517
+ return out;
6518
+ }
6452
6519
 
6453
6520
  // src/mobile/teamsOps.ts
6454
6521
  var FAILED = { status: "failed" };
6455
6522
  var invalid = { status: "rejected", reason: "invalid" };
6523
+ function withSeats(models) {
6524
+ const seats = (models ?? []).flatMap((x) => {
6525
+ if (x.code) return [{ role: x.role, session: x.session, status: "refused", reason: x.code }];
6526
+ return x.when ? [{ role: x.role, session: x.session, status: x.when === "now" ? "applied" : "scheduled" }] : [];
6527
+ });
6528
+ return seats.length ? { models: seats } : {};
6529
+ }
6456
6530
  function createTeamsOps(deps) {
6457
6531
  const listing = async () => {
6458
6532
  let found;
@@ -6506,10 +6580,12 @@ function createTeamsOps(deps) {
6506
6580
  if (!m.code) return true;
6507
6581
  if (m.code === "missing") return FAILED;
6508
6582
  return { status: "rejected", reason: m.code };
6509
- case "team.changed":
6583
+ case "team.changed": {
6510
6584
  if (m.rid !== rid) return void 0;
6511
- if (m.reason && m.team && type === "team.compose") return { status: "applied", reason: "seat_failed" };
6512
- return !m.reason;
6585
+ const seats = withSeats(m.models);
6586
+ if (m.reason && m.team && type === "team.compose") return { status: "applied", reason: "seat_failed", ...seats };
6587
+ return m.reason ? FAILED : { status: "applied", ...seats };
6588
+ }
6513
6589
  case "team.launched":
6514
6590
  return m.rid === rid ? true : void 0;
6515
6591
  case "team.failed":
@@ -6704,12 +6780,14 @@ function verifyAssertion(a, cred, rp, challenge) {
6704
6780
  // src/mobile/gateway.ts
6705
6781
  var FLUSH_BEFORE_REVOKE_MS = 5e3;
6706
6782
  var REQUEST_NAME = /^[a-z][a-z.]{0,39}$/;
6783
+ var MODELS_WAIT_MS = 2e4;
6707
6784
  function createPhoneGateway(deps) {
6708
6785
  const { mobile, log: log2 } = deps;
6709
6786
  const startedAt = new Date(deps.now ? deps.now() : Date.now()).toISOString();
6710
6787
  const registry = new OperationRegistry();
6711
6788
  const followers = /* @__PURE__ */ new Map();
6712
6789
  let stopped2 = false;
6790
+ const modelFailures = /* @__PURE__ */ new Map();
6713
6791
  const follower = (deviceId) => {
6714
6792
  let f = followers.get(deviceId);
6715
6793
  if (!f) followers.set(deviceId, f = { subscribed: false, watching: null, reads: /* @__PURE__ */ new Map(), visible: false });
@@ -6752,7 +6830,17 @@ function createPhoneGateway(deps) {
6752
6830
  const s = deps.session(id);
6753
6831
  return s && !isArchived(s) ? s : void 0;
6754
6832
  };
6755
- const projected = (deviceId, s) => toPhoneSession(s, followers.get(deviceId)?.reads.get(s.id) ?? startedAt);
6833
+ const checklists = /* @__PURE__ */ new Map();
6834
+ const computeChecklist = (id) => {
6835
+ const todos = latestTodos(deps.thread(id) ?? []);
6836
+ return { list: toPhoneChecklist(todos), summary: checklistSummary(todos) };
6837
+ };
6838
+ const checklistOf = (id) => {
6839
+ let c = checklists.get(id);
6840
+ if (!c) checklists.set(id, c = computeChecklist(id));
6841
+ return c;
6842
+ };
6843
+ const projected = (deviceId, s) => toPhoneSession(s, followers.get(deviceId)?.reads.get(s.id) ?? startedAt, checklistOf(s.id).summary);
6756
6844
  const pushStatus = (deviceId) => {
6757
6845
  const key = mobile.relayPushKey();
6758
6846
  if (key === null) return "unavailable";
@@ -6764,14 +6852,15 @@ function createPhoneGateway(deps) {
6764
6852
  const push2 = pushStatus(deviceId);
6765
6853
  const f = followers.get(deviceId);
6766
6854
  if (f) f.pushStatus = push2;
6767
- return {
6855
+ const message = {
6768
6856
  type: "snapshot",
6769
6857
  protocol: PHONE_PROTOCOL,
6770
6858
  sessions: deps.sessions().filter((s) => !isArchived(s)).map((s) => projected(deviceId, s)),
6771
6859
  capabilities: capabilities(),
6772
- 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 } : {} },
6860
+ 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, ...deps.closeSession ? { sessionClose: true } : {} },
6773
6861
  device: { confirm: deps.credential(deviceId) !== null, push: push2, ...deps.chats ? { chat: true } : {}, ...deps.contextNear ? { contextNear: deps.contextNear() } : {} }
6774
6862
  };
6863
+ return deps.models ? { ...message, daemon: { ...message.daemon, models: true } } : message;
6775
6864
  };
6776
6865
  const phoneTeams = () => (deps.teams?.() ?? []).flatMap((t) => {
6777
6866
  const x = toPhoneTeam(t, deps.tasks?.() ?? [], deps.projectName ?? (() => null));
@@ -6793,6 +6882,8 @@ function createPhoneGateway(deps) {
6793
6882
  if (followers.get(deviceId)?.subscribed) {
6794
6883
  outbox.push(deviceId, snapshot(deviceId), "snapshot");
6795
6884
  teamsAndTasks(deviceId);
6885
+ const watching = followers.get(deviceId)?.watching;
6886
+ if (watching) outbox.push(deviceId, { type: "session.checklist", id: watching, checklist: checklistOf(watching).list }, `session.checklist:${watching}`);
6796
6887
  }
6797
6888
  },
6798
6889
  log: log2,
@@ -6801,9 +6892,27 @@ function createPhoneGateway(deps) {
6801
6892
  const push = (deviceId, msg, key) => {
6802
6893
  if (!stopped2) outbox.push(deviceId, msg, key);
6803
6894
  };
6804
- const result = (deviceId, op, status, reason) => {
6895
+ const replayModelFailures = (deviceId) => {
6896
+ for (const [id, f] of modelFailures) if (live3(id)) push(deviceId, { type: "session.model.failed", id, reason: f.reason });
6897
+ };
6898
+ const keepModelFailure = (m) => {
6899
+ if (m.type === "session.removed") {
6900
+ modelFailures.delete(m.id);
6901
+ return;
6902
+ }
6903
+ if (m.type === "session.updated") {
6904
+ const f = modelFailures.get(m.session.id);
6905
+ if (f && (isArchived(m.session) || f.model !== m.session.model || f.effort !== m.session.effort)) modelFailures.delete(m.session.id);
6906
+ return;
6907
+ }
6908
+ if (m.type !== "error" || m.request !== "session.model" || m.id === void 0) return;
6909
+ const reason = ModelRefusalSchema.safeParse(m.code);
6910
+ const s = live3(m.id);
6911
+ if (reason.success && s) modelFailures.set(m.id, { reason: reason.data, model: s.model, effort: s.effort });
6912
+ };
6913
+ const result = (deviceId, op, status, reason, models, how) => {
6805
6914
  if (stopped2 || !approved(deviceId)) return;
6806
- push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {} });
6915
+ push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {}, ...models ? { models } : {}, ...how ? { how } : {} });
6807
6916
  };
6808
6917
  const needsAssertion = (msg, s) => {
6809
6918
  if (msg.type === "session.decide") return msg.decision !== "deny" && riskOf2(s)?.risk !== "low";
@@ -6865,6 +6974,9 @@ function createPhoneGateway(deps) {
6865
6974
  return s.pending.requestId === msg.requestId ? void 0 : { status: "rejected", reason: "stale" };
6866
6975
  case "session.interrupt":
6867
6976
  return s.tmuxSession || backendOf(s) === "omp-rpc" ? void 0 : { status: "rejected", reason: "read_only" };
6977
+ case "session.model":
6978
+ if (canSwitch(s)) return void 0;
6979
+ return { status: "rejected", reason: s.state === "exited" ? "ended" : "not_launched" };
6868
6980
  }
6869
6981
  };
6870
6982
  const local = (msg) => {
@@ -6877,6 +6989,8 @@ function createPhoneGateway(deps) {
6877
6989
  return { type: "session.answer", id: msg.id, requestId: msg.requestId, answers: msg.answers, ...msg.cancel ? { cancel: true } : {} };
6878
6990
  case "session.interrupt":
6879
6991
  return { type: "session.interrupt", id: msg.id };
6992
+ case "session.model":
6993
+ return { type: "session.model", id: msg.id, model: msg.model, effort: msg.effort };
6880
6994
  }
6881
6995
  };
6882
6996
  const outcomeOf = (code) => {
@@ -6985,6 +7099,39 @@ function createPhoneGateway(deps) {
6985
7099
  throw e;
6986
7100
  }
6987
7101
  };
7102
+ const sessionSubject = (id) => `session:${id}`;
7103
+ const sessionEnd = async (deviceId, msg) => {
7104
+ const close = deps.closeSession;
7105
+ if (!close) return { status: "rejected", reason: "unavailable" };
7106
+ const closing = msg.type === "session.close";
7107
+ const s = live3(msg.id);
7108
+ if (!s) return { status: "rejected", reason: "unknown_session" };
7109
+ if (s.source === "hook_only") return { status: "rejected", reason: "read_only" };
7110
+ if (closing && s.state === "exited") return { status: "rejected", reason: "ended" };
7111
+ if (!closing && s.state !== "exited") return { status: "rejected", reason: "busy" };
7112
+ if (deps.credential(deviceId) === null) return { status: "rejected", reason: "confirm_required" };
7113
+ const refused = checkAssertion(deviceId, msg.assertion, closing ? "close" : "act", sessionSubject(msg.id));
7114
+ if (refused) return refused;
7115
+ if (!closing) {
7116
+ let error;
7117
+ await deps.dispatch({ type: "session.resume", id: msg.id }, (m) => {
7118
+ if (m.type === "error" && m.request === "session.resume") error ??= { code: m.code };
7119
+ });
7120
+ if (!error) return { status: "applied" };
7121
+ return error.code === "no_conversation" ? { status: "rejected", reason: "no_conversation" } : outcomeOf(error.code);
7122
+ }
7123
+ try {
7124
+ const { how } = await close(msg.id);
7125
+ log2(`phone: device ${deviceId} closed ${msg.id} (${how})`);
7126
+ return { status: "applied", how };
7127
+ } catch (e) {
7128
+ if (live3(msg.id)?.state === "exited") return { status: "rejected", reason: "ended" };
7129
+ const code = RefusalCodeSchema.safeParse(e instanceof Error ? e.code : void 0);
7130
+ if (code.success) return { status: "rejected", reason: code.data };
7131
+ log2(`phone session.close failed (${e instanceof Error ? e.name : "error"})`);
7132
+ return { status: "failed" };
7133
+ }
7134
+ };
6988
7135
  const ask = async (out, ok2, opts = {}) => {
6989
7136
  let outcome;
6990
7137
  await deps.dispatch(out, (m) => {
@@ -6998,6 +7145,41 @@ function createPhoneGateway(deps) {
6998
7145
  });
6999
7146
  return outcome ?? opts.silent ?? { status: "applied" };
7000
7147
  };
7148
+ const modelChange = async (out) => {
7149
+ let when;
7150
+ let refusal2;
7151
+ await deps.dispatch(out, (m) => {
7152
+ if (m.type === "session.model.accepted" && m.id === out.id) when ??= m.when;
7153
+ if (m.type === "error" && m.request === "session.model" && (m.id === void 0 || m.id === out.id)) refusal2 ??= { code: m.code };
7154
+ });
7155
+ if (when) modelFailures.delete(out.id);
7156
+ if (when) return when === "turn" ? { status: "applied", reason: "scheduled" } : { status: "applied" };
7157
+ const model = ModelRefusalSchema.safeParse(refusal2?.code);
7158
+ if (model.success) return { status: "rejected", reason: model.data };
7159
+ const known2 = RefusalCodeSchema.safeParse(refusal2?.code);
7160
+ return known2.success ? { status: "rejected", reason: known2.data } : { status: "failed" };
7161
+ };
7162
+ const modelsList = async (deviceId, tool) => {
7163
+ const requestId = randomUUID5();
7164
+ let listing;
7165
+ let timer;
7166
+ try {
7167
+ await Promise.race([
7168
+ deps.dispatch({ type: "models.list", requestId, tool }, (m) => {
7169
+ if (m.type === "models.listing" && m.requestId === requestId) listing ??= m;
7170
+ }),
7171
+ new Promise((r) => {
7172
+ timer = setTimeout(r, MODELS_WAIT_MS);
7173
+ })
7174
+ ]);
7175
+ } catch (e) {
7176
+ log2(`phone models.list failed (${e instanceof Error ? e.name : "error"})`);
7177
+ } finally {
7178
+ clearTimeout(timer);
7179
+ }
7180
+ if (!listing) return push(deviceId, { type: "models.listing", tool, models: [], error: true });
7181
+ push(deviceId, { type: "models.listing", tool, models: toPhoneModels(listing.models), ...listing.error !== void 0 ? { error: true } : {} });
7182
+ };
7001
7183
  const chatTeam = async (deviceId, msg) => {
7002
7184
  const turn = ownTurn(deviceId, msg.qid);
7003
7185
  if (!turn?.team || !deps.teams) return { status: "rejected", reason: "unknown_session" };
@@ -7075,9 +7257,11 @@ function createPhoneGateway(deps) {
7075
7257
  if (msg.type === "chat.launch") return chatLaunch(deviceId, msg);
7076
7258
  if (msg.type === "chat.close") return chatClose(deviceId, msg);
7077
7259
  if (msg.type === "chat.act") return chatAct(deviceId, msg);
7260
+ if (msg.type === "session.close" || msg.type === "session.resume") return sessionEnd(deviceId, msg);
7078
7261
  const refused = precheck(deviceId, msg);
7079
7262
  if (refused) return refused;
7080
7263
  const out = local(msg);
7264
+ if (out.type === "session.model") return modelChange(out);
7081
7265
  let error;
7082
7266
  await deps.dispatch(out, (m) => {
7083
7267
  if (m.type === "error" && (m.request === void 0 || m.request === out.type)) error ??= { code: m.code };
@@ -7087,7 +7271,7 @@ function createPhoneGateway(deps) {
7087
7271
  const write = async (deviceId, msg) => {
7088
7272
  const begun = registry.begin(deviceId, msg.op, fingerprint(msg));
7089
7273
  if (begun.kind === "conflict") return result(deviceId, msg.op, "rejected", "conflict");
7090
- if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason);
7274
+ if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason, begun.record.models, begun.record.how);
7091
7275
  let outcome;
7092
7276
  try {
7093
7277
  result(deviceId, msg.op, "received");
@@ -7096,8 +7280,8 @@ function createPhoneGateway(deps) {
7096
7280
  log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`);
7097
7281
  outcome = { status: "failed" };
7098
7282
  }
7099
- registry.finish(msg.op, outcome.status, outcome.reason);
7100
- result(deviceId, msg.op, outcome.status, outcome.reason);
7283
+ registry.finish(msg.op, outcome.status, outcome.reason, outcome.models, outcome.how);
7284
+ result(deviceId, msg.op, outcome.status, outcome.reason, outcome.models, outcome.how);
7101
7285
  if (msg.type === "credential.register" && outcome.status === "applied" && approved(deviceId)) push(deviceId, snapshot(deviceId), "snapshot");
7102
7286
  if (msg.type === "device.forget" && outcome.status === "applied") {
7103
7287
  let timer;
@@ -7184,13 +7368,19 @@ function createPhoneGateway(deps) {
7184
7368
  case "presence":
7185
7369
  follower(deviceId).visible = msg.visible;
7186
7370
  return;
7371
+ case "models.list":
7372
+ if (!deps.models) return;
7373
+ void modelsList(deviceId, msg.tool).catch((e) => log2(`phone models.list failed (${e instanceof Error ? e.name : "error"})`));
7374
+ return;
7187
7375
  case "snapshot.request":
7188
7376
  follower(deviceId).subscribed = true;
7189
7377
  push(deviceId, snapshot(deviceId), "snapshot");
7378
+ replayModelFailures(deviceId);
7190
7379
  return teamsAndTasks(deviceId);
7191
7380
  case "session.thread": {
7192
7381
  if (!live3(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
7193
- return push(deviceId, { type: "session.thread", id: msg.id, ...toPhoneThread(deps.thread(msg.id) ?? []) });
7382
+ push(deviceId, { type: "session.thread", id: msg.id, ...toPhoneThread(deps.thread(msg.id) ?? []) });
7383
+ return push(deviceId, { type: "session.checklist", id: msg.id, checklist: checklistOf(msg.id).list }, `session.checklist:${msg.id}`);
7194
7384
  }
7195
7385
  case "session.pending": {
7196
7386
  const session = live3(msg.id);
@@ -7228,6 +7418,14 @@ function createPhoneGateway(deps) {
7228
7418
  const challenge = deps.challenges.issue({ deviceId, purpose: "launch", subject: msg.qid });
7229
7419
  return push(deviceId, { type: "confirm.challenge", purpose: "launch", challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
7230
7420
  }
7421
+ if ((msg.purpose === "close" || msg.purpose === "act") && msg.qid === void 0 && msg.id !== void 0) {
7422
+ if (!deps.closeSession) return invalid2();
7423
+ if (!live3(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
7424
+ const cred = deps.credential(deviceId);
7425
+ if (!cred || cred.rpId !== rp.rpId) return invalid2();
7426
+ const challenge = deps.challenges.issue({ deviceId, purpose: msg.purpose, subject: sessionSubject(msg.id) });
7427
+ return push(deviceId, { type: "confirm.challenge", purpose: msg.purpose, challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
7428
+ }
7231
7429
  if (msg.purpose === "close") {
7232
7430
  if (msg.qid === void 0 || !ownTurn(deviceId, msg.qid)) return invalid2();
7233
7431
  const cred = deps.credential(deviceId);
@@ -7291,7 +7489,8 @@ function createPhoneGateway(deps) {
7291
7489
  const request = typeof type === "string" && REQUEST_NAME.test(type) ? type : void 0;
7292
7490
  log2(`phone message refused: ${request ?? "?"}`);
7293
7491
  const op = OperationIdSchema.safeParse(raw?.op);
7294
- 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" });
7492
+ if (request === "session.model" && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
7493
+ 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" || request === "session.close" || request === "session.resume") && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
7295
7494
  return push(deviceId, { type: "error", code: "invalid", ...request !== void 0 ? { request } : {} });
7296
7495
  }
7297
7496
  const msg = parsed.data;
@@ -7324,17 +7523,40 @@ function createPhoneGateway(deps) {
7324
7523
  }
7325
7524
  };
7326
7525
  const removed = (id) => {
7526
+ checklists.delete(id);
7327
7527
  for (const [deviceId, f] of followers) {
7328
7528
  f.reads.delete(id);
7329
7529
  if (f.watching === id) f.watching = null;
7330
7530
  if (f.subscribed) push(deviceId, { type: "session.removed", id });
7331
7531
  }
7332
7532
  };
7533
+ const checklistChanged = (id, item) => {
7534
+ if (item.kind !== "tool" || item.helperId !== void 0 || !TODO_TOOLS.has(item.tool) || !checklists.has(id)) return;
7535
+ const before = checklists.get(id);
7536
+ const now = computeChecklist(id);
7537
+ checklists.set(id, now);
7538
+ if (JSON.stringify(now.list) !== JSON.stringify(before.list)) {
7539
+ for (const [deviceId, f] of followers) if (f.watching === id) push(deviceId, { type: "session.checklist", id, checklist: now.list }, `session.checklist:${id}`);
7540
+ }
7541
+ const s = live3(id);
7542
+ if (s && JSON.stringify(now.summary) !== JSON.stringify(before.summary)) {
7543
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "session.updated", session: projected(deviceId, s) }, `session.updated:${id}`);
7544
+ }
7545
+ };
7333
7546
  const unsubscribeMessage = mobile.onMessage(receive);
7334
7547
  const unsubscribeChange = mobile.onChange(pruneRevoked);
7335
7548
  return {
7336
7549
  forward(m) {
7337
- if (stopped2 || followers.size === 0) return;
7550
+ if (!stopped2) try {
7551
+ keepModelFailure(m);
7552
+ } catch (e) {
7553
+ log2(`phone model failure of ${m.type} failed (${e instanceof Error ? e.name : "error"})`);
7554
+ }
7555
+ if (stopped2) return;
7556
+ if (followers.size === 0) {
7557
+ checklists.clear();
7558
+ return;
7559
+ }
7338
7560
  try {
7339
7561
  switch (m.type) {
7340
7562
  case "session.updated":
@@ -7345,6 +7567,12 @@ function createPhoneGateway(deps) {
7345
7567
  return;
7346
7568
  case "session.removed":
7347
7569
  return removed(m.id);
7570
+ case "error": {
7571
+ const reason = ModelRefusalSchema.safeParse(m.code);
7572
+ if (m.request !== "session.model" || m.id === void 0 || !reason.success || !live3(m.id)) return;
7573
+ for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, { type: "session.model.failed", id: m.id, reason: reason.data });
7574
+ return;
7575
+ }
7348
7576
  case "context.prefs":
7349
7577
  for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, snapshot(deviceId), "snapshot");
7350
7578
  return;
@@ -7376,6 +7604,7 @@ function createPhoneGateway(deps) {
7376
7604
  }
7377
7605
  return;
7378
7606
  case "session.thread.item": {
7607
+ checklistChanged(m.id, m.item);
7379
7608
  const watchers = [...followers].filter(([, f]) => f.watching === m.id);
7380
7609
  if (watchers.length === 0) return;
7381
7610
  const { item, cut: cut2 } = toPhoneItem(m.item);
@@ -8156,6 +8385,7 @@ async function startServer(opts) {
8156
8385
  async function applyOmpModel(s) {
8157
8386
  const next2 = s.modelNext;
8158
8387
  typing.add(s.id);
8388
+ applyingModel.set(s.id, null);
8159
8389
  try {
8160
8390
  await omp.setModel(s.id, next2);
8161
8391
  log(`model of ${s.id} is now ${next2.model ?? "unchanged"}${next2.effort ? ` \xB7 ${next2.effort}` : ""}`);
@@ -8163,32 +8393,91 @@ async function startServer(opts) {
8163
8393
  const message = e.message;
8164
8394
  log(`model change of ${s.id} failed: ${message}`);
8165
8395
  store.patch(s.id, { modelNext: void 0 });
8166
- broadcast({ type: "error", id: s.id, message, request: "session.model" });
8396
+ modelFailed(s.id, message, "refused");
8167
8397
  } finally {
8168
8398
  typing.delete(s.id);
8399
+ afterModelApply(s.id);
8400
+ }
8401
+ }
8402
+ const modelFailed = (id, message, code) => broadcast({ type: "error", id, message, request: "session.model", code });
8403
+ const applyingModel = /* @__PURE__ */ new Map();
8404
+ function afterModelApply(id) {
8405
+ const later = applyingModel.get(id);
8406
+ applyingModel.delete(id);
8407
+ const s = store.get(id);
8408
+ if (!later || !s) return;
8409
+ const refusal2 = modelRefusal(s, later.effort ?? null);
8410
+ if (refusal2) {
8411
+ store.patch(id, { modelNext: void 0 });
8412
+ return modelFailed(id, refusal2.message, refusal2.refused);
8169
8413
  }
8414
+ store.patch(id, { modelNext: later.model === s.model && later.effort === s.effort ? void 0 : later });
8415
+ }
8416
+ function canApplyNow(id) {
8417
+ return free(store.get(id)) && !typing.has(id) && !closing.has(id) && !cardThreads.queuedRelays().some((q) => q.relay.id === id);
8418
+ }
8419
+ function modelRefusal(s, effort) {
8420
+ const inPlace = backendOf(s) === "omp-rpc";
8421
+ if (s.source === "hook_only") return { refused: "not_launched", message: READ_ONLY2 };
8422
+ if (s.state === "exited") return { refused: "ended", message: "the session has ended" };
8423
+ if (!resumeKey(s)) return { refused: "no_conversation", message: "no conversation to resume" };
8424
+ if (inPlace && !omp.isLive(s.id)) return { refused: "not_running", message: "the session is not running" };
8425
+ if (effort && !inPlace && !EFFORT_ARGS[s.tool]) return { refused: "effort_in_model", message: `${s.tool} takes its effort in the model id` };
8426
+ return null;
8427
+ }
8428
+ async function changeModel(id, model, effort) {
8429
+ const s = store.get(id);
8430
+ if (!s) throw unknownSession();
8431
+ const refusal2 = modelRefusal(s, effort);
8432
+ if (refusal2) return refusal2;
8433
+ const next2 = { ...model ? { model } : {}, ...effort ? { effort } : {} };
8434
+ if (applyingModel.has(id)) {
8435
+ applyingModel.set(id, next2);
8436
+ store.patch(id, { modelNext: next2 });
8437
+ return { when: "turn" };
8438
+ }
8439
+ if (next2.model === s.model && next2.effort === s.effort) {
8440
+ if (s.modelNext) store.patch(id, { modelNext: void 0 });
8441
+ return { when: "none" };
8442
+ }
8443
+ const when = canApplyNow(id) ? "now" : "turn";
8444
+ store.patch(id, { modelNext: next2 });
8445
+ return { when };
8170
8446
  }
8171
8447
  async function applyModelNext(id) {
8172
8448
  const s = store.get(id);
8173
- if (!s?.modelNext || !free(s) || typing.has(id) || closing.has(id) || cardThreads.queuedRelays().some((q) => q.relay.id === id)) return;
8449
+ if (!s?.modelNext || !canApplyNow(id)) return;
8174
8450
  if (backendOf(s) === "omp-rpc") return applyOmpModel(s);
8175
- const key = resumeKey(s);
8176
- if (!key || s.source !== "launcher") {
8451
+ const { model, effort } = s.modelNext;
8452
+ const refusal2 = modelRefusal(s, effort ?? null);
8453
+ if (refusal2) {
8177
8454
  store.patch(id, { modelNext: void 0 });
8178
- return;
8455
+ return modelFailed(id, refusal2.message, refusal2.refused);
8179
8456
  }
8180
- const { model, effort } = s.modelNext;
8457
+ const key = resumeKey(s);
8181
8458
  typing.add(id);
8459
+ applyingModel.set(id, null);
8182
8460
  try {
8183
8461
  for (const [k, at] of respawned) if (Date.now() - at > RESPAWN_HOOKS_MS) respawned.delete(k);
8184
8462
  respawned.set(id, Date.now());
8185
8463
  await endSession(s);
8186
8464
  const ended = store.get(id);
8187
- if (!ended || ended.archivedAt !== void 0) return;
8465
+ if (!ended || ended.archivedAt !== void 0) {
8466
+ store.patch(id, { modelNext: void 0 });
8467
+ return;
8468
+ }
8188
8469
  const refused = await respawn({ ...ended, modelNext: void 0 }, key, model, effort);
8189
- if (refused) log(`model change of ${id} failed: ${refused}`);
8470
+ if (refused) {
8471
+ log(`model change of ${id} failed: ${refused}`);
8472
+ modelFailed(id, refused, "restart_failed");
8473
+ }
8474
+ } catch (e) {
8475
+ log(`model change of ${id} failed: ${e.message}`);
8476
+ store.patch(id, { modelNext: void 0 });
8477
+ modelFailed(id, e.message, "restart_failed");
8190
8478
  } finally {
8191
8479
  typing.delete(id);
8480
+ afterModelApply(id);
8192
8481
  }
8193
8482
  }
8194
8483
  const chatLaunches = /* @__PURE__ */ new Set();
@@ -8451,6 +8740,26 @@ ${now2.prompt}` });
8451
8740
  for (const id of leaverRefused) if (!leaving.has(id)) leaverRefused.delete(id);
8452
8741
  }
8453
8742
  const seatsInFlight = /* @__PURE__ */ new Set();
8743
+ async function seatModels(before, team) {
8744
+ const out = [];
8745
+ const roles = listDefinitions(team.cwd).roles;
8746
+ for (const seat of team.seats) {
8747
+ const was = before.get(seat.role);
8748
+ if (!was || seat.leaving || was.tool !== seat.tool || was.model === seat.model && was.effort === seat.effort) continue;
8749
+ const holder = seat.sessionId ? store.get(seat.sessionId) : void 0;
8750
+ if (!holder) continue;
8751
+ const resolved = resolveSeats([seat], roles);
8752
+ const want = "unknown" in resolved ? seat : resolved[0];
8753
+ try {
8754
+ const r = await changeModel(holder.id, want.model ?? null, want.effort ?? null);
8755
+ if ("refused" in r) out.push({ role: seat.role, session: holder.id, code: r.refused });
8756
+ else if (r.when !== "none") out.push({ role: seat.role, session: holder.id, when: r.when });
8757
+ } catch (e) {
8758
+ log(`team ${team.id}: model of @${seat.role} (${holder.id}) not changed: ${e.message}`);
8759
+ }
8760
+ }
8761
+ return out;
8762
+ }
8454
8763
  async function composeTeam(msg) {
8455
8764
  const changed = (extra) => ({ type: "team.changed", rid: msg.rid, ...extra });
8456
8765
  const t = teams.get(msg.team);
@@ -8467,12 +8776,14 @@ ${now2.prompt}` });
8467
8776
  if (busy) return changed({ reason: `@${busy.role} : une op\xE9ration est d\xE9j\xE0 en cours sur ce si\xE8ge` });
8468
8777
  for (const k of keys) seatsInFlight.add(k);
8469
8778
  try {
8779
+ const before = new Map(t.seats.map((s) => [s.role, s]));
8470
8780
  const r = teams.compose(msg.team, msg.seats);
8471
8781
  if (typeof r === "string") return changed({ reason: r });
8472
8782
  log(`team ${t.id} composed: +${r.added.join(",") || "-"} \u2212${r.removed.join(",") || "-"}`);
8473
8783
  const failed = r.added.length ? await startSeats(r.team, msg.seats.filter((s) => r.added.includes(s.role))) : null;
8474
8784
  closeLeavers();
8475
- return changed({ team: teams.get(t.id), ...failed ? { reason: failed } : {} });
8785
+ const models2 = await seatModels(before, teams.get(t.id));
8786
+ return changed({ team: teams.get(t.id), ...failed ? { reason: failed } : {}, ...models2.length ? { models: models2 } : {} });
8476
8787
  } finally {
8477
8788
  for (const k of keys) seatsInFlight.delete(k);
8478
8789
  }
@@ -8807,27 +9118,16 @@ ${now2.prompt}` });
8807
9118
  if (!s) return refuse(unknownSession(), msg.id);
8808
9119
  if (s.state !== "exited") return refuse(new Refusal("the session is still running", "busy"), msg.id);
8809
9120
  const key = resumeKey(s);
8810
- if (!key) return refuse("no conversation to resume", msg.id);
9121
+ if (!key) return refuse({ message: "no conversation to resume", code: "no_conversation" }, msg.id);
8811
9122
  const refused = await respawn(s, key, s.model, s.effort);
8812
9123
  if (refused) refuse(refused, msg.id);
8813
9124
  return;
8814
9125
  }
8815
9126
  case "session.model": {
8816
- const s = store.get(msg.id);
8817
- if (!s) return refuse("unknown session", msg.id);
8818
- if (s.source === "hook_only") return refuse(READ_ONLY2, msg.id);
8819
- if (s.state === "exited") return refuse("the session has ended", msg.id);
8820
- if (!resumeKey(s)) return refuse("no conversation to resume", msg.id);
8821
- if (backendOf(s) === "omp-rpc" && !omp.isLive(s.id)) return refuse("the session is not running", msg.id);
8822
- if (msg.effort && backendOf(s) !== "omp-rpc" && !EFFORT_ARGS[s.tool]) return refuse(`${s.tool} takes its effort in the model id`, msg.id);
8823
- const next2 = { ...msg.model ? { model: msg.model } : {}, ...msg.effort ? { effort: msg.effort } : {} };
8824
- if (next2.model === s.model && next2.effort === s.effort) {
8825
- if (s.modelNext) store.patch(msg.id, { modelNext: void 0 });
8826
- return;
8827
- }
8828
- store.patch(msg.id, { modelNext: next2 });
8829
- await applyModelNext(msg.id);
8830
- return;
9127
+ if (!store.get(msg.id)) return refuse("unknown session", msg.id);
9128
+ const r = await changeModel(msg.id, msg.model, msg.effort);
9129
+ if ("refused" in r) return refuse({ message: r.message, code: r.refused }, msg.id);
9130
+ return reply({ type: "session.model.accepted", id: msg.id, when: r.when });
8831
9131
  }
8832
9132
  case "session.delete": {
8833
9133
  const s = store.get(msg.id);
@@ -9093,7 +9393,7 @@ ${now2.prompt}` });
9093
9393
  },
9094
9394
  onChatEnd: (deviceId, end) => notifyChatEnd?.(deviceId, end.code),
9095
9395
  version: opts.daemon?.version ?? "unknown",
9096
- boot: randomUUID5(),
9396
+ boot: randomUUID6(),
9097
9397
  log,
9098
9398
  now,
9099
9399
  rp: () => rpOf(mobile.state().relayUrl),
@@ -9107,6 +9407,10 @@ ${now2.prompt}` });
9107
9407
  // A project's folder is its first local repo: only a project with one can be named by the phone.
9108
9408
  projectByName: (name) => projects.projects.find((p) => p.name === name && p.repos.some((r) => r.machine === "local"))?.id ?? null,
9109
9409
  teamCatalog: true,
9410
+ // The phone's model switch (#58): `session.model` answers with a code, and `models.list` asks this machine's agents.
9411
+ models: true,
9412
+ // A session's « Fermer » on the phone: the cards' close, so a close under way (a card's, another phone's) is shared.
9413
+ closeSession: closeForCard,
9110
9414
  // The phone's « Mettre à jour le PC » and « Redémarrer le daemon »: the updater Desktop's `update.daemon` uses, progress broadcast.
9111
9415
  ...opts.updater && opts.daemon ? {
9112
9416
  pc: {