@paradow/ledge 0.27.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-7SJJTIEA.js → action-NQRSZEC5.js} +4 -4
  2. package/dist/cli/{attach-V7UPHVB2.js → attach-KXOSFUHO.js} +8 -8
  3. package/dist/cli/{bridge-JK3PMG7T.js → bridge-XCTXVWKH.js} +1 -1
  4. package/dist/cli/{chunk-RA3OIXEQ.js → chunk-3CF4FQWG.js} +2 -2
  5. package/dist/cli/{chunk-7OGFH6KJ.js → chunk-3NQNWGDX.js} +2 -2
  6. package/dist/cli/{chunk-TOSPL5H2.js → chunk-CIQAFCX3.js} +3 -3
  7. package/dist/cli/{chunk-IGAGJVRZ.js → chunk-CR3LGTZQ.js} +3 -3
  8. package/dist/cli/{chunk-UPKJO3BZ.js → chunk-JHFKBKSO.js} +1 -1
  9. package/dist/cli/{chunk-2N7PSP7C.js → chunk-JLUHY5AB.js} +3 -3
  10. package/dist/cli/{chunk-ADAGKOFO.js → chunk-LG3XFIPV.js} +1 -1
  11. package/dist/cli/{chunk-CVHX7GUB.js → chunk-OYDTU6AP.js} +302 -46
  12. package/dist/cli/{chunk-YLN3VOGV.js → chunk-QSRB3F5Y.js} +2 -2
  13. package/dist/cli/{chunk-ISNHG5SJ.js → chunk-RYZ6FHYT.js} +5 -5
  14. package/dist/cli/chunk-UME2F7VM.js +6 -0
  15. package/dist/cli/{chunk-37G5F75H.js → chunk-VKRFHDRK.js} +124 -9
  16. package/dist/cli/{chunk-7GIUG73R.js → chunk-VMGACWNP.js} +1 -1
  17. package/dist/cli/{chunk-7JRQCKBP.js → chunk-ZI47U5CQ.js} +2 -2
  18. package/dist/cli/{daemon-YUPB5NUG.js → daemon-ZUHJKLSL.js} +7 -7
  19. package/dist/cli/{decide-HFI2WQXI.js → decide-OSSTI3FE.js} +3 -3
  20. package/dist/cli/{handover-GKORMXZ2.js → handover-2G7OEWIG.js} +4 -4
  21. package/dist/cli/{hook-TJJEFV54.js → hook-XY4E5H7O.js} +9 -9
  22. package/dist/cli/index.js +16 -16
  23. package/dist/cli/{launch-QKQZZSIM.js → launch-7WOARLZM.js} +8 -8
  24. package/dist/cli/{send-RI7JOO5K.js → send-RUHGKLMA.js} +4 -4
  25. package/dist/cli/{status-RQRS26S2.js → status-4Z2VJFID.js} +2 -2
  26. package/dist/cli/{task-U2XHBUKW.js → task-2GESYTOL.js} +4 -4
  27. package/dist/cli/{team-NFC4XEIA.js → team-MTSKNMAE.js} +6 -6
  28. package/dist/cli/{tui-UWR7SDYF.js → tui-4IRYQXYU.js} +8 -8
  29. package/dist/cli/{version-YYNMMWKJ.js → version-L2Y7MRH6.js} +1 -1
  30. package/dist/cli/{who-WW6IL4KD.js → who-PSKHASMK.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-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-7JRQCKBP.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-UPKJO3BZ.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-ADAGKOFO.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,
@@ -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-VKRFHDRK.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,14 @@ 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) {
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;
6061
6073
  }
6062
6074
  get(op) {
6063
6075
  this.expire();
@@ -6209,7 +6221,27 @@ function toPhoneRequest(s, bodyMax) {
6209
6221
  return toPhonePending(s.pending, pendingKey(s.pending), verdict.risk, verdict.reason, bodyMax);
6210
6222
  }
6211
6223
  var toPhoneFullRequest = (s) => toPhoneRequest(s, PENDING_BODY_MAX);
6212
- 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) {
6213
6245
  const pending = toPhoneRequest(s, SNAPSHOT_BODY_MAX);
6214
6246
  return {
6215
6247
  id: s.id,
@@ -6225,10 +6257,19 @@ function toPhoneSession(s, readAt2) {
6225
6257
  ...pickSet(s, ["repo", "branch", "worktree", "role", "roleLabel", "team", "context", "relieves"]),
6226
6258
  // Its relief's state only (context handover spec §3.2): the phone shows it, the PC starts it.
6227
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 } : {},
6228
6268
  helpers: s.helpers.map((h) => ({ id: h.id, kind: h.kind, task: h.task, state: h.state, ...pickSet(h, ["finishedAt"]) })),
6229
6269
  // A preview only: the whole reply is in the thread.
6230
6270
  ...s.lastReply ? { lastReply: { text: clip(s.lastReply.text, TOOL_TEXT_MAX).text, at: s.lastReply.at, ...pickSet(s.lastReply, ["plan"]) } } : {},
6231
6271
  ...pending ? { pending } : {},
6272
+ ...checklist ? { checklist } : {},
6232
6273
  unread: !!s.lastReply && s.lastReply.at > readAt2
6233
6274
  };
6234
6275
  }
@@ -6449,10 +6490,39 @@ function toPhoneTask(x, owner, teamName) {
6449
6490
  };
6450
6491
  }
6451
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
+ }
6452
6515
 
6453
6516
  // src/mobile/teamsOps.ts
6454
6517
  var FAILED = { status: "failed" };
6455
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
+ }
6456
6526
  function createTeamsOps(deps) {
6457
6527
  const listing = async () => {
6458
6528
  let found;
@@ -6506,10 +6576,12 @@ function createTeamsOps(deps) {
6506
6576
  if (!m.code) return true;
6507
6577
  if (m.code === "missing") return FAILED;
6508
6578
  return { status: "rejected", reason: m.code };
6509
- case "team.changed":
6579
+ case "team.changed": {
6510
6580
  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;
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
+ }
6513
6585
  case "team.launched":
6514
6586
  return m.rid === rid ? true : void 0;
6515
6587
  case "team.failed":
@@ -6704,12 +6776,14 @@ function verifyAssertion(a, cred, rp, challenge) {
6704
6776
  // src/mobile/gateway.ts
6705
6777
  var FLUSH_BEFORE_REVOKE_MS = 5e3;
6706
6778
  var REQUEST_NAME = /^[a-z][a-z.]{0,39}$/;
6779
+ var MODELS_WAIT_MS = 2e4;
6707
6780
  function createPhoneGateway(deps) {
6708
6781
  const { mobile, log: log2 } = deps;
6709
6782
  const startedAt = new Date(deps.now ? deps.now() : Date.now()).toISOString();
6710
6783
  const registry = new OperationRegistry();
6711
6784
  const followers = /* @__PURE__ */ new Map();
6712
6785
  let stopped2 = false;
6786
+ const modelFailures = /* @__PURE__ */ new Map();
6713
6787
  const follower = (deviceId) => {
6714
6788
  let f = followers.get(deviceId);
6715
6789
  if (!f) followers.set(deviceId, f = { subscribed: false, watching: null, reads: /* @__PURE__ */ new Map(), visible: false });
@@ -6752,7 +6826,17 @@ function createPhoneGateway(deps) {
6752
6826
  const s = deps.session(id);
6753
6827
  return s && !isArchived(s) ? s : void 0;
6754
6828
  };
6755
- 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);
6756
6840
  const pushStatus = (deviceId) => {
6757
6841
  const key = mobile.relayPushKey();
6758
6842
  if (key === null) return "unavailable";
@@ -6764,14 +6848,15 @@ function createPhoneGateway(deps) {
6764
6848
  const push2 = pushStatus(deviceId);
6765
6849
  const f = followers.get(deviceId);
6766
6850
  if (f) f.pushStatus = push2;
6767
- return {
6851
+ const message = {
6768
6852
  type: "snapshot",
6769
6853
  protocol: PHONE_PROTOCOL,
6770
6854
  sessions: deps.sessions().filter((s) => !isArchived(s)).map((s) => projected(deviceId, s)),
6771
6855
  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 } : {} },
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 },
6773
6857
  device: { confirm: deps.credential(deviceId) !== null, push: push2, ...deps.chats ? { chat: true } : {}, ...deps.contextNear ? { contextNear: deps.contextNear() } : {} }
6774
6858
  };
6859
+ return deps.models ? { ...message, daemon: { ...message.daemon, models: true } } : message;
6775
6860
  };
6776
6861
  const phoneTeams = () => (deps.teams?.() ?? []).flatMap((t) => {
6777
6862
  const x = toPhoneTeam(t, deps.tasks?.() ?? [], deps.projectName ?? (() => null));
@@ -6793,6 +6878,8 @@ function createPhoneGateway(deps) {
6793
6878
  if (followers.get(deviceId)?.subscribed) {
6794
6879
  outbox.push(deviceId, snapshot(deviceId), "snapshot");
6795
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}`);
6796
6883
  }
6797
6884
  },
6798
6885
  log: log2,
@@ -6801,9 +6888,27 @@ function createPhoneGateway(deps) {
6801
6888
  const push = (deviceId, msg, key) => {
6802
6889
  if (!stopped2) outbox.push(deviceId, msg, key);
6803
6890
  };
6804
- 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) => {
6805
6910
  if (stopped2 || !approved(deviceId)) return;
6806
- push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {} });
6911
+ push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {}, ...models ? { models } : {} });
6807
6912
  };
6808
6913
  const needsAssertion = (msg, s) => {
6809
6914
  if (msg.type === "session.decide") return msg.decision !== "deny" && riskOf2(s)?.risk !== "low";
@@ -6865,6 +6970,9 @@ function createPhoneGateway(deps) {
6865
6970
  return s.pending.requestId === msg.requestId ? void 0 : { status: "rejected", reason: "stale" };
6866
6971
  case "session.interrupt":
6867
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" };
6868
6976
  }
6869
6977
  };
6870
6978
  const local = (msg) => {
@@ -6877,6 +6985,8 @@ function createPhoneGateway(deps) {
6877
6985
  return { type: "session.answer", id: msg.id, requestId: msg.requestId, answers: msg.answers, ...msg.cancel ? { cancel: true } : {} };
6878
6986
  case "session.interrupt":
6879
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 };
6880
6990
  }
6881
6991
  };
6882
6992
  const outcomeOf = (code) => {
@@ -6998,6 +7108,41 @@ function createPhoneGateway(deps) {
6998
7108
  });
6999
7109
  return outcome ?? opts.silent ?? { status: "applied" };
7000
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
+ };
7001
7146
  const chatTeam = async (deviceId, msg) => {
7002
7147
  const turn = ownTurn(deviceId, msg.qid);
7003
7148
  if (!turn?.team || !deps.teams) return { status: "rejected", reason: "unknown_session" };
@@ -7078,6 +7223,7 @@ function createPhoneGateway(deps) {
7078
7223
  const refused = precheck(deviceId, msg);
7079
7224
  if (refused) return refused;
7080
7225
  const out = local(msg);
7226
+ if (out.type === "session.model") return modelChange(out);
7081
7227
  let error;
7082
7228
  await deps.dispatch(out, (m) => {
7083
7229
  if (m.type === "error" && (m.request === void 0 || m.request === out.type)) error ??= { code: m.code };
@@ -7087,7 +7233,7 @@ function createPhoneGateway(deps) {
7087
7233
  const write = async (deviceId, msg) => {
7088
7234
  const begun = registry.begin(deviceId, msg.op, fingerprint(msg));
7089
7235
  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);
7236
+ if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason, begun.record.models);
7091
7237
  let outcome;
7092
7238
  try {
7093
7239
  result(deviceId, msg.op, "received");
@@ -7096,8 +7242,8 @@ function createPhoneGateway(deps) {
7096
7242
  log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`);
7097
7243
  outcome = { status: "failed" };
7098
7244
  }
7099
- registry.finish(msg.op, outcome.status, outcome.reason);
7100
- 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);
7101
7247
  if (msg.type === "credential.register" && outcome.status === "applied" && approved(deviceId)) push(deviceId, snapshot(deviceId), "snapshot");
7102
7248
  if (msg.type === "device.forget" && outcome.status === "applied") {
7103
7249
  let timer;
@@ -7184,13 +7330,19 @@ function createPhoneGateway(deps) {
7184
7330
  case "presence":
7185
7331
  follower(deviceId).visible = msg.visible;
7186
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;
7187
7337
  case "snapshot.request":
7188
7338
  follower(deviceId).subscribed = true;
7189
7339
  push(deviceId, snapshot(deviceId), "snapshot");
7340
+ replayModelFailures(deviceId);
7190
7341
  return teamsAndTasks(deviceId);
7191
7342
  case "session.thread": {
7192
7343
  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) ?? []) });
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}`);
7194
7346
  }
7195
7347
  case "session.pending": {
7196
7348
  const session = live3(msg.id);
@@ -7291,6 +7443,7 @@ function createPhoneGateway(deps) {
7291
7443
  const request = typeof type === "string" && REQUEST_NAME.test(type) ? type : void 0;
7292
7444
  log2(`phone message refused: ${request ?? "?"}`);
7293
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" });
7294
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" });
7295
7448
  return push(deviceId, { type: "error", code: "invalid", ...request !== void 0 ? { request } : {} });
7296
7449
  }
@@ -7324,17 +7477,40 @@ function createPhoneGateway(deps) {
7324
7477
  }
7325
7478
  };
7326
7479
  const removed = (id) => {
7480
+ checklists.delete(id);
7327
7481
  for (const [deviceId, f] of followers) {
7328
7482
  f.reads.delete(id);
7329
7483
  if (f.watching === id) f.watching = null;
7330
7484
  if (f.subscribed) push(deviceId, { type: "session.removed", id });
7331
7485
  }
7332
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
+ };
7333
7500
  const unsubscribeMessage = mobile.onMessage(receive);
7334
7501
  const unsubscribeChange = mobile.onChange(pruneRevoked);
7335
7502
  return {
7336
7503
  forward(m) {
7337
- 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
+ }
7338
7514
  try {
7339
7515
  switch (m.type) {
7340
7516
  case "session.updated":
@@ -7345,6 +7521,12 @@ function createPhoneGateway(deps) {
7345
7521
  return;
7346
7522
  case "session.removed":
7347
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
+ }
7348
7530
  case "context.prefs":
7349
7531
  for (const [deviceId, f] of followers) if (f.subscribed) push(deviceId, snapshot(deviceId), "snapshot");
7350
7532
  return;
@@ -7376,6 +7558,7 @@ function createPhoneGateway(deps) {
7376
7558
  }
7377
7559
  return;
7378
7560
  case "session.thread.item": {
7561
+ checklistChanged(m.id, m.item);
7379
7562
  const watchers = [...followers].filter(([, f]) => f.watching === m.id);
7380
7563
  if (watchers.length === 0) return;
7381
7564
  const { item, cut: cut2 } = toPhoneItem(m.item);
@@ -8156,6 +8339,7 @@ async function startServer(opts) {
8156
8339
  async function applyOmpModel(s) {
8157
8340
  const next2 = s.modelNext;
8158
8341
  typing.add(s.id);
8342
+ applyingModel.set(s.id, null);
8159
8343
  try {
8160
8344
  await omp.setModel(s.id, next2);
8161
8345
  log(`model of ${s.id} is now ${next2.model ?? "unchanged"}${next2.effort ? ` \xB7 ${next2.effort}` : ""}`);
@@ -8163,32 +8347,91 @@ async function startServer(opts) {
8163
8347
  const message = e.message;
8164
8348
  log(`model change of ${s.id} failed: ${message}`);
8165
8349
  store.patch(s.id, { modelNext: void 0 });
8166
- broadcast({ type: "error", id: s.id, message, request: "session.model" });
8350
+ modelFailed(s.id, message, "refused");
8167
8351
  } finally {
8168
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);
8169
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 };
8170
8400
  }
8171
8401
  async function applyModelNext(id) {
8172
8402
  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;
8403
+ if (!s?.modelNext || !canApplyNow(id)) return;
8174
8404
  if (backendOf(s) === "omp-rpc") return applyOmpModel(s);
8175
- const key = resumeKey(s);
8176
- if (!key || s.source !== "launcher") {
8405
+ const { model, effort } = s.modelNext;
8406
+ const refusal2 = modelRefusal(s, effort ?? null);
8407
+ if (refusal2) {
8177
8408
  store.patch(id, { modelNext: void 0 });
8178
- return;
8409
+ return modelFailed(id, refusal2.message, refusal2.refused);
8179
8410
  }
8180
- const { model, effort } = s.modelNext;
8411
+ const key = resumeKey(s);
8181
8412
  typing.add(id);
8413
+ applyingModel.set(id, null);
8182
8414
  try {
8183
8415
  for (const [k, at] of respawned) if (Date.now() - at > RESPAWN_HOOKS_MS) respawned.delete(k);
8184
8416
  respawned.set(id, Date.now());
8185
8417
  await endSession(s);
8186
8418
  const ended = store.get(id);
8187
- if (!ended || ended.archivedAt !== void 0) return;
8419
+ if (!ended || ended.archivedAt !== void 0) {
8420
+ store.patch(id, { modelNext: void 0 });
8421
+ return;
8422
+ }
8188
8423
  const refused = await respawn({ ...ended, modelNext: void 0 }, key, model, effort);
8189
- 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");
8190
8432
  } finally {
8191
8433
  typing.delete(id);
8434
+ afterModelApply(id);
8192
8435
  }
8193
8436
  }
8194
8437
  const chatLaunches = /* @__PURE__ */ new Set();
@@ -8451,6 +8694,26 @@ ${now2.prompt}` });
8451
8694
  for (const id of leaverRefused) if (!leaving.has(id)) leaverRefused.delete(id);
8452
8695
  }
8453
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
+ }
8454
8717
  async function composeTeam(msg) {
8455
8718
  const changed = (extra) => ({ type: "team.changed", rid: msg.rid, ...extra });
8456
8719
  const t = teams.get(msg.team);
@@ -8467,12 +8730,14 @@ ${now2.prompt}` });
8467
8730
  if (busy) return changed({ reason: `@${busy.role} : une op\xE9ration est d\xE9j\xE0 en cours sur ce si\xE8ge` });
8468
8731
  for (const k of keys) seatsInFlight.add(k);
8469
8732
  try {
8733
+ const before = new Map(t.seats.map((s) => [s.role, s]));
8470
8734
  const r = teams.compose(msg.team, msg.seats);
8471
8735
  if (typeof r === "string") return changed({ reason: r });
8472
8736
  log(`team ${t.id} composed: +${r.added.join(",") || "-"} \u2212${r.removed.join(",") || "-"}`);
8473
8737
  const failed = r.added.length ? await startSeats(r.team, msg.seats.filter((s) => r.added.includes(s.role))) : null;
8474
8738
  closeLeavers();
8475
- 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 } : {} });
8476
8741
  } finally {
8477
8742
  for (const k of keys) seatsInFlight.delete(k);
8478
8743
  }
@@ -8813,21 +9078,10 @@ ${now2.prompt}` });
8813
9078
  return;
8814
9079
  }
8815
9080
  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;
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 });
8831
9085
  }
8832
9086
  case "session.delete": {
8833
9087
  const s = store.get(msg.id);
@@ -9093,7 +9347,7 @@ ${now2.prompt}` });
9093
9347
  },
9094
9348
  onChatEnd: (deviceId, end) => notifyChatEnd?.(deviceId, end.code),
9095
9349
  version: opts.daemon?.version ?? "unknown",
9096
- boot: randomUUID5(),
9350
+ boot: randomUUID6(),
9097
9351
  log,
9098
9352
  now,
9099
9353
  rp: () => rpOf(mobile.state().relayUrl),
@@ -9107,6 +9361,8 @@ ${now2.prompt}` });
9107
9361
  // A project's folder is its first local repo: only a project with one can be named by the phone.
9108
9362
  projectByName: (name) => projects.projects.find((p) => p.name === name && p.repos.some((r) => r.machine === "local"))?.id ?? null,
9109
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,
9110
9366
  // The phone's « Mettre à jour le PC » and « Redémarrer le daemon »: the updater Desktop's `update.daemon` uses, progress broadcast.
9111
9367
  ...opts.updater && opts.daemon ? {
9112
9368
  pc: {