@paigy/harness 0.3.37 → 0.3.38

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 (3) hide show
  1. package/dist/cli.js +245 -27
  2. package/dist/main.js +244 -26
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -77,7 +77,7 @@ function runProbe(argv) {
77
77
  function detect(entry, deps = {}) {
78
78
  const find = deps.which ?? which;
79
79
  const probe = deps.probe ?? runProbe;
80
- const base = { name: entry.name, label: entry.label };
80
+ const base = { name: entry.name, label: entry.label, models: entry.models };
81
81
  const cliPath = find(entry.cli);
82
82
  if (!cliPath) return { ...base, status: "missing", hint: `${entry.label} is not installed.`, install: entry.installCli };
83
83
  const adapterPath = entry.adapter ? find(entry.adapter) : null;
@@ -109,7 +109,8 @@ var init_catalog = __esm({
109
109
  authProbe: ["claude", "auth", "status"],
110
110
  loginHint: "Run `claude` once in a terminal and sign in.",
111
111
  installCli: "curl -fsSL https://claude.ai/install.sh | bash",
112
- installAdapter: "npm install -g @agentclientprotocol/claude-agent-acp"
112
+ installAdapter: "npm install -g @agentclientprotocol/claude-agent-acp",
113
+ models: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229"]
113
114
  },
114
115
  {
115
116
  name: "codex",
@@ -119,7 +120,8 @@ var init_catalog = __esm({
119
120
  authProbe: ["codex", "login", "status"],
120
121
  loginHint: "Run `codex login` to authenticate.",
121
122
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
122
- installAdapter: "npm install -g @agentclientprotocol/codex-acp"
123
+ installAdapter: "npm install -g @agentclientprotocol/codex-acp",
124
+ models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"]
123
125
  },
124
126
  {
125
127
  name: "agy",
@@ -129,7 +131,8 @@ var init_catalog = __esm({
129
131
  adapter: "paigy-agy-acp",
130
132
  authProbe: ["agy", "help"],
131
133
  loginHint: "Install and set up Antigravity (`agy`).",
132
- installCli: "curl -fsSL https://antigravity.dev/install.sh | bash"
134
+ installCli: "curl -fsSL https://antigravity.dev/install.sh | bash",
135
+ models: ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-pro-exp"]
133
136
  }
134
137
  ];
135
138
  }
@@ -22777,10 +22780,47 @@ function claudeConversation() {
22777
22780
  if (!envSession("CLAUDE_CODE_SESSION_ID") || process.env.CLAUDE_CODE_CHILD_SESSION) return void 0;
22778
22781
  const dir = envSession("CLAUDE_CONFIG_DIR") ?? join2(homedir2(), ".claude");
22779
22782
  for (const pid of ancestors()) {
22783
+ let text;
22780
22784
  try {
22781
- const { sessionId: sessionId2 } = JSON.parse(readFileSync(join2(dir, "sessions", `${pid}.json`), "utf8"));
22782
- return typeof sessionId2 === "string" && sessionId2.trim() ? sessionId2 : void 0;
22785
+ text = readFileSync(join2(dir, "sessions", `${pid}.json`), "utf8");
22783
22786
  } catch {
22787
+ continue;
22788
+ }
22789
+ const record = parseSessionRecord(text);
22790
+ if (!record) continue;
22791
+ return typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId : void 0;
22792
+ }
22793
+ return void 0;
22794
+ }
22795
+ function parseSessionRecord(text) {
22796
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
22797
+ try {
22798
+ return object(JSON.parse(text));
22799
+ } catch {
22800
+ }
22801
+ let depth = 0;
22802
+ let start = -1;
22803
+ let quoted = false;
22804
+ let escaped = false;
22805
+ for (let i = 0; i < text.length; i += 1) {
22806
+ const c = text[i];
22807
+ if (quoted) {
22808
+ if (escaped) escaped = false;
22809
+ else if (c === "\\") escaped = true;
22810
+ else if (c === '"') quoted = false;
22811
+ } else if (c === '"') quoted = true;
22812
+ else if (c === "{") {
22813
+ if (depth === 0) start = i;
22814
+ depth += 1;
22815
+ } else if (c === "}" && depth > 0) {
22816
+ depth -= 1;
22817
+ if (depth === 0) {
22818
+ try {
22819
+ return object(JSON.parse(text.slice(start, i + 1)));
22820
+ } catch {
22821
+ return void 0;
22822
+ }
22823
+ }
22784
22824
  }
22785
22825
  }
22786
22826
  return void 0;
@@ -24254,7 +24294,7 @@ ${block2}`;
24254
24294
  }
24255
24295
  return text.slice(0, start) + updated + text.slice(end);
24256
24296
  }
24257
- var import_undici, require2, CODEX_ENV, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, lineage, CLIENT, ignoreOverride, defaultOptions, getDefaultOptions, getRefs, getRelativePath, parseCatchDef, integerDateParser, isJsonSchema7AllOfType, emojiRegex2, zodPatterns, ALPHA_NUMERIC, primitiveMappings, asAnyOf, parseOptionalDef, parsePipelineDef, parseReadonlyDef, selectParser, get$ref, addMeta, zodToJsonSchema, OPTIONS_MIN, OPTIONS_MAX, OPTION_LABEL_MAX, OPTION_HINT_MAX, OptionSchema, OptionInputSchema, NIGHT, ASK_MAX, UNITS_MAX, UNIT_TITLE_MAX, UNIT_BODY_MAX, UnitInputSchema, questionHandle, NodeRef, BlockedAction, reason, actionFits, ACTION_FITS, userExplicitlyRequested, units, UpdateInputSchema, questionFields, refineQuestion, QuestionInputSchema, QuestionWireSchema, AnswerInputSchema, QuestionDependencySchema, sendFields, ContactSchema, sendsAnything, ContactWireSchema, CONTACT_SCHEMA, CONTACT_DESCRIPTION, CreateGoalSchema, UpdateGoalSchema, changedGoal, goalTitle, GOAL_OUTCOME_MAX, goalOutcome, CreateChange, EditChange, StateChange, AssignChange, DeferChange, MoveChange, DependencyChange, read, GoalChangeSchema, editNames, ManageGoalsSchema, ManageGoalsToolSchema, MANAGE_GOALS_DESCRIPTION, GetGoalSchema, GET_GOAL_DESCRIPTION, SearchToolSchema, SEARCH_DESCRIPTION, CheckActivitySchema, FEEDBACK_TEXT_MAX, SendFeedbackSchema, SEND_FEEDBACK_DESCRIPTION, AGENT_TOOLS, AGENT_TOOL_NAMES, id, key, participant, SourceSchema, ResultRefSchema, NodeRefSchema, DependencySchema, goalState, existingGoalChanges, BrainGoalChangeSchema, option, QuestionChangeSchema, lessonScope, LessonChangeSchema, BrainNextSchema, messageBase, BrainMessageSchema, BrainSearchSchema, BrainResultSchema, str, strs, CompactResultSchema, COMPACT_RESULT_JSON_SCHEMA, str2, strs2, TalkerReplySchema, TALKER_REPLY_JSON_SCHEMA, str3, strs3, FilerReplySchema, FILER_REPLY_JSON_SCHEMA, ExplainerReplySchema, EXPLAINER_REPLY_JSON_SCHEMA, DISTILLER_DEFECTS, DistillerReplySchema, DISTILLER_REPLY_JSON_SCHEMA, TIDY_AUDIENCES, TidyReplySchema, TIDY_REPLY_JSON_SCHEMA, LIVE_MS, WORKING_MS, ContextSchema, ParticipantSchema, TransformSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, clock, QUIET, BrokerTuningSchema, UserSettingsSchema, HistoryWorkSchema, HistoryEntrySchema, HistoryPageSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, LedgerItemSchema, AgentLedgerSchema, ReassignResultSchema, LessonStateSchema, LessonViewSchema, QueueQuestionSchema, QueueReplySchema, QueueItemSchema, QueueDeltaSchema, COLD_AFTER_MS, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, WAKE_EVENT, wakeChannel, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, HostDecisionSchema, WakeNudgeSchema, PairingStatusSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenSchema, SupportRequestSchema, NotificationFeedbackKindSchema, SlimOptionSchema, QuestionRowSchema, WorkStateSchema, WorkRowSchema, ComputerRowSchema, AgentRowSchema, SnapshotSchema, CallRecapSchema, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, RevokedError, ApiError, tokenOverride, authToken, AWAIT_WINDOW_MS, NOT_SENT, APPLIED, DEMOTED, HEARTBEAT_MS, RETRY_MS, JOIN, JOIN_TIMEOUT_MS, lines;
24297
+ var import_undici, require2, CODEX_ENV, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, lineage, CLIENT, ignoreOverride, defaultOptions, getDefaultOptions, getRefs, getRelativePath, parseCatchDef, integerDateParser, isJsonSchema7AllOfType, emojiRegex2, zodPatterns, ALPHA_NUMERIC, primitiveMappings, asAnyOf, parseOptionalDef, parsePipelineDef, parseReadonlyDef, selectParser, get$ref, addMeta, zodToJsonSchema, OPTIONS_MIN, OPTIONS_MAX, OPTION_LABEL_MAX, OPTION_HINT_MAX, OptionSchema, OptionInputSchema, NIGHT, ASK_MAX, UNITS_MAX, UNIT_TITLE_MAX, UNIT_BODY_MAX, UnitInputSchema, questionHandle, NodeRef, BlockedAction, reason, actionFits, ACTION_FITS, userExplicitlyRequested, units, UpdateInputSchema, questionFields, refineQuestion, QuestionInputSchema, QuestionWireSchema, AnswerInputSchema, QuestionDependencySchema, sendFields, ContactSchema, sendsAnything, ContactWireSchema, CONTACT_SCHEMA, CONTACT_DESCRIPTION, CreateGoalSchema, UpdateGoalSchema, changedGoal, goalTitle, GOAL_OUTCOME_MAX, goalOutcome, CreateChange, EditChange, StateChange, AssignChange, DeferChange, MoveChange, DependencyChange, read, GoalChangeSchema, editNames, ManageGoalsSchema, ManageGoalsToolSchema, MANAGE_GOALS_DESCRIPTION, GetGoalSchema, GET_GOAL_DESCRIPTION, SearchToolSchema, SEARCH_DESCRIPTION, CheckActivitySchema, FEEDBACK_TEXT_MAX, SendFeedbackSchema, SEND_FEEDBACK_DESCRIPTION, AGENT_TOOLS, AGENT_TOOL_NAMES, id, key, participant, SourceSchema, ResultRefSchema, NodeRefSchema, DependencySchema, goalState, existingGoalChanges, BrainGoalChangeSchema, option, QuestionChangeSchema, lessonScope, LessonChangeSchema, BrainNextSchema, messageBase, BrainMessageSchema, BrainSearchSchema, BrainResultSchema, str, strs, CompactResultSchema, COMPACT_RESULT_JSON_SCHEMA, str2, strs2, TalkerReplySchema, TALKER_REPLY_JSON_SCHEMA, str3, strs3, FilerReplySchema, FILER_REPLY_JSON_SCHEMA, ExplainerReplySchema, EXPLAINER_REPLY_JSON_SCHEMA, DISTILLER_DEFECTS, DistillerReplySchema, DISTILLER_REPLY_JSON_SCHEMA, TIDY_AUDIENCES, TidyReplySchema, TIDY_REPLY_JSON_SCHEMA, LIVE_MS, WORKING_MS, WORKING_TIER_MS, RECENT_TIER_MS, ContextSchema, ParticipantSchema, TransformSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, clock, QUIET, BrokerTuningSchema, UserSettingsSchema, HistoryWorkSchema, HistoryEntrySchema, HistoryPageSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, LedgerItemSchema, AgentLedgerSchema, ReassignResultSchema, LessonStateSchema, LessonViewSchema, QueueQuestionSchema, QueueReplySchema, QueueItemSchema, QueueDeltaSchema, COLD_AFTER_MS, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, WAKE_EVENT, wakeChannel, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, HostDecisionSchema, WakeNudgeSchema, PairingStatusSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenSchema, SupportRequestSchema, NotificationFeedbackKindSchema, SlimOptionSchema, QuestionRowSchema, WorkStateSchema, WorkRowSchema, ComputerRowSchema, AgentRowSchema, SnapshotSchema, CallRecapSchema, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, RevokedError, ApiError, tokenOverride, authToken, AWAIT_WINDOW_MS, NOT_SENT, APPLIED, DEMOTED, HEARTBEAT_MS, RETRY_MS, JOIN, JOIN_TIMEOUT_MS, lines;
24258
24298
  var init_dist = __esm({
24259
24299
  "../../packages/sdk/dist/index.js"() {
24260
24300
  "use strict";
@@ -24705,8 +24745,41 @@ var init_dist = __esm({
24705
24745
  message: external_exports.string().trim().min(1).describe(`The one point, at most ${ASK_MAX} characters; the context goes in units.`),
24706
24746
  goalIds: external_exports.array(external_exports.string().uuid()).min(1).max(10).describe("The Goals it is about, each one that exists: the one it is mainly about first."),
24707
24747
  units,
24748
+ // A REPORT KEEPS ITS TAP (#2799, #3260; owner, 2026-10-08, settling #2776: "keep the reports open
24749
+ // until the user acknowledges them"). A report asks nothing, so it opens no Question -- but it
24750
+ // still waits to be acknowledged, and it carries the ONE tap that closes it. Never a decision: the
24751
+ // tap lands as a contribution, the person's own words on the Goal, because there is no Question
24752
+ // for it to answer.
24753
+ //
24754
+ // AND ITS WORDS ARE WRITTEN FOR THIS REPORT (owner, 2026-10-08, Goal 3c12d3c6: "Agent
24755
+ // acknowledgments vary (e.g. 'sounds good, I will test later') instead of always 'got it'"). `Got
24756
+ // it` and `Noted` say only that a card was cleared, where "Sounds good, I'll test later" says the
24757
+ // work was RECEIVED and not verified -- the distinction a session spent an hour recovering from a
24758
+ // call transcript on 2026-10-07, having read "not quite" as a defect report when it meant *not
24759
+ // tested yet*. The words are worth writing because the agent reads them back.
24760
+ //
24761
+ // ONE STRING, NOT A LIST (owner, 2026-10-08 15:47, Goal 31a1e2ac, asked whether an agent should
24762
+ // write up to four of them: "just on the cards, but it should be more like just they should only
24763
+ // get one basically string value that they can enter that replaces got it"). A report has exactly
24764
+ // one way to close, so the only thing an agent writes is what that button SAYS. #3260's 1..6
24765
+ // `options` and this field are one job: the list is deleted, not deprecated (ONE JOB, ONE
24766
+ // MECHANISM). It rides the door as the report's single stored option (`storedOption`,
24767
+ // `apps/api/src/goal/router.ts`), so no reader below the door learns a second shape.
24768
+ reply: external_exports.string().trim().min(1).describe(
24769
+ `What this report's button says instead of "Got it", at most ${OPTION_LABEL_MAX} characters, written in THEIR voice for THIS report ("Sounds good, I'll test later") -- the care you give a question's options, so what they tap tells you whether they verified your work or only received it. One tap sends exactly these words back to you. Words that would CHANGE what happens next are a question, not a reply to a report: it still asks nothing and still owes you no answer. Left out, the button says "Got it", as every card did.`
24770
+ ).optional(),
24708
24771
  userExplicitlyRequested
24709
- }).strict().superRefine((u, ctx) => refuseOverCaps(u.message, u.units, "message", ctx));
24772
+ }).strict().superRefine((u, ctx) => {
24773
+ refuseOverCaps(u.message, u.units, "message", ctx);
24774
+ if (u.reply && u.reply.length > OPTION_LABEL_MAX) {
24775
+ ctx.addIssue({
24776
+ code: external_exports.ZodIssueCode.custom,
24777
+ path: ["reply"],
24778
+ params: { refusal: "reply_too_long", length: u.reply.length, cap: OPTION_LABEL_MAX },
24779
+ message: `reply_too_long: the reply is ${u.reply.length} characters; the cap is ${OPTION_LABEL_MAX}. It is a button's words, so say it in a breath.`
24780
+ });
24781
+ }
24782
+ });
24710
24783
  questionFields = {
24711
24784
  question: external_exports.string().trim().min(1).describe(
24712
24785
  `ONE question, in at most ${ASK_MAX} characters, and only what is needed to answer it; the context goes in units. Several questions are several objects, one each: an answer settles the one question it was given, so a person who answers the part of a bundled question that interested them settles nothing and is asked again. Separate questions cost nothing: ANY contact for a person already on a call JOINS that call, so several arrive as one call.`
@@ -24790,6 +24863,8 @@ QUESTIONS: one question per object; several questions are several objects in one
24790
24863
 
24791
24864
  CUT A LONG MESSAGE BEFORE YOU SEND IT: a question, an update's message or an answer is the one point, in at most ${ASK_MAX} characters; everything else it needs goes in 'units', up to ${UNITS_MAX} {title, body} in reading order, one idea each, every title at most ${UNIT_TITLE_MAX} characters and every body at most ${UNIT_BODY_MAX}. The person sees the main text and the titles, and opens a unit to read its body. Over a cap is refused by name (ask_too_long, too_many_units, unit_title_too_long, unit_body_too_long) and nothing is sent.
24792
24865
 
24866
+ GIVE A REPORT ITS REPLY: an update takes \`reply\` too, one line the person can tap instead of typing, written in THEIR voice for THAT report ("Sounds good, I'll test later") \u2014 the same care you give a question's options. It is what their button says instead of "Got it", and what they tap is what you read back, which tells you whether they verified your work or only received it. One line, because a report has one way to close, and it may not change what happens next: a reply that would is a question. Nothing is owed on it either way, and a report that writes none offers "Got it".
24867
+
24793
24868
  UPDATES ask nothing, so no answer is owed and none should be awaited. An update reaches the person only when they asked you for it (userExplicitlyRequested), when it answers something they said, or once its Goal is done; any other is recorded as the Goal's progress and nobody is notified. So when the work is finished, mark the Goal done first (manage_goals), then send one update saying what is done and anything they need to do or check. On a Goal whose report card is still open, an update that reaches them is added to that card, with no new push. If the person is already on a call, anything that reaches them joins that call, with no ring.
24794
24869
 
24795
24870
  RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of what is addressed to you (not a history page): \`question\`, a Question you owe (answer it in answers, with its questionId); \`update\`, something new on one of your Goals (a reply, an answer: read it with get_goal); \`instruction\`, a request or note sent to you. \`hasMore\` says more are waiting. Reading acknowledges nothing: once you have handled events, confirm their eventIds with contact({ackEventIds}), and the next batch can come. contact({}) waits up to about 45 seconds for something to arrive; contact({wait:false}) returns at once. It also lists work given to you that nobody has started (\`assigned\`; your first write to it starts it) and your work gone quiet (\`stalled\`).`;
@@ -25144,8 +25219,10 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
25144
25219
  // something the person answered or asked for did not save, was refused or dropped
25145
25220
  "ignored",
25146
25221
  // the person asked or said something and was never answered (the critic's ignored_a_question)
25147
- "promised"
25222
+ "promised",
25148
25223
  // said it did or would do something, and nothing shows it done (the critic's broken_promise)
25224
+ "dropped"
25225
+ // something it had to say never played and was never said later (owner, 2026-10-08, call 65433a9d)
25149
25226
  ];
25150
25227
  DistillerReplySchema = external_exports.object({
25151
25228
  defects: external_exports.array(external_exports.object({
@@ -25176,6 +25253,8 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
25176
25253
  );
25177
25254
  LIVE_MS = 3 * 6e4;
25178
25255
  WORKING_MS = 60 * 6e4;
25256
+ WORKING_TIER_MS = 2 * 60 * 6e4;
25257
+ RECENT_TIER_MS = 24 * 60 * 6e4;
25179
25258
  ContextSchema = external_exports.object({
25180
25259
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
25181
25260
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -25520,6 +25599,10 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
25520
25599
  * so "who said that" survives the call, and it rides PER TURN because a coalesced call
25521
25600
  * speaks for several agents — the turn is the only place that knows which. */
25522
25601
  agentId: external_exports.string().optional(),
25602
+ /** ITS QUESTION IS ON THE TABLE (#3292): a talker line about a question the person owes. The walk tells
25603
+ * the phone (`asking`, so the agenda goes gold and its options show) without treating the line as a
25604
+ * question turn — the talker owns re-asking and the waits, so `question` stays null. */
25605
+ onTable: external_exports.boolean().optional(),
25523
25606
  select: SelectShapeSchema.optional(),
25524
25607
  options: external_exports.array(OptionSchema.omit({ id: true })).optional(),
25525
25608
  /* `pace` STOOD HERE (#826). A turn could carry seconds and the model chose them. The walk
@@ -25594,9 +25677,16 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
25594
25677
  /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
25595
25678
  * its need offers, exactly as its own card carries them, present only when one of them has a
25596
25679
  * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
25597
- * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
25598
- * says those, and the list stays small (an `html` is up to 16 KB). */
25599
- options: external_exports.array(OptionSchema).optional()
25680
+ * never re-sent as a second card to be seen mid-call. Words-only options are `choices`. */
25681
+ options: external_exports.array(OptionSchema).optional(),
25682
+ /** ITS OPTIONS WHEN THEY ARE WORDS (owner, 2026-10-02, on a call: "whenever there are pre-made
25683
+ * options … now for a checklist, we should also show them on the screen"; design C, 2026-10-08):
25684
+ * numbered chips above the call's controls, so a checklist is never only something read aloud. A
25685
+ * field of its own so `options` keeps meaning "previews": a phone on an older bundle opens the
25686
+ * preview layout for any `options`, and must see exactly what it did. */
25687
+ choices: external_exports.array(OptionSchema).optional(),
25688
+ /** HOW MANY MAY BE PICKED, beside its `choices`: one, any (`many`) or an order (`rank`). */
25689
+ select: external_exports.enum(["one", "many", "rank"]).optional()
25600
25690
  })).optional(),
25601
25691
  visuals: external_exports.array(VisualSchema).optional(),
25602
25692
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -26033,7 +26123,11 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
26033
26123
  from: external_exports.enum(["person", "agent"]),
26034
26124
  said: external_exports.string(),
26035
26125
  at: external_exports.string(),
26036
- entryId: external_exports.string()
26126
+ entryId: external_exports.string(),
26127
+ /** ITS SUMMARY IN AT MOST TEN WORDS (owner, 2026-10-08: "the same switch to the more summarized sources
26128
+ * at the work tab top level screen too"): what the talker or filer wrote on the Entry; absent when
26129
+ * none was. The Work row reads it before `said`. */
26130
+ summary: external_exports.string().optional()
26037
26131
  }).nullable().optional(),
26038
26132
  /** WHEN THIS PERSON LAST PUT A HAND ON IT THEMSELVES (owner, Paigy Goal 16d18f51, 2026-09-30):
26039
26133
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
@@ -26370,6 +26464,9 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
26370
26464
  title: external_exports.string(),
26371
26465
  owner: external_exports.string(),
26372
26466
  state: external_exports.string(),
26467
+ /** Whether the call put it to the person. An item it carried and never said has no row on the
26468
+ * review (owner, 2026-10-08: "why do we get the third one when it didn't happen in the call?"). */
26469
+ presented: external_exports.boolean(),
26373
26470
  questions: external_exports.array(external_exports.object({ id: external_exports.string().uuid(), state: external_exports.string(), title: external_exports.string() })),
26374
26471
  /** `words` is always what they SAID, verbatim — the record, never replaced. `summary` is what
26375
26472
  * the line says in ten words (owner, 2026-10-07): the filer's, or the talker's for the answer it
@@ -26377,9 +26474,33 @@ RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of w
26377
26474
  * neither wrote one. */
26378
26475
  /** `about` is the request the line answered (its question), null for words that answered none —
26379
26476
  * the key the screen groups on, so one question is one row however many times it was answered. */
26380
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), summary: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
26477
+ /** `reply` is the reply the line makes up (an answer, an ok, a deferral), so one reply is one row
26478
+ * however many lines it cites; `at` is the call lines it came from, so a filed line an answer already
26479
+ * said is not drawn again (owner, 2026-10-07: "fix them all"); `replyKind` says which replies are answers. */
26480
+ lines: external_exports.array(external_exports.object({
26481
+ entryId: external_exports.string().uuid(),
26482
+ words: external_exports.string(),
26483
+ summary: external_exports.string().optional(),
26484
+ about: external_exports.string().nullable().optional(),
26485
+ reply: external_exports.string().nullable().optional(),
26486
+ replyKind: external_exports.string().nullable().optional(),
26487
+ at: external_exports.array(external_exports.number()).optional()
26488
+ }))
26381
26489
  })),
26382
26490
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
26491
+ /** THE PUBLIC TRANSCRIPT — every line said on the call, in the order it was said, and WHO SAID IT
26492
+ * (owner, 2026-10-08: "the public view should only have the lines and who said them, and that's
26493
+ * it"; `docs/brain/phase-three/modular.md` build step 3). `speaker` is the role: "user" is the
26494
+ * person, "agent" an agent speaking, named in `agent`. The timeline's action lines are ours, not
26495
+ * theirs, and are not here; nor is a sealed line, which the server has no words for. It stands
26496
+ * BESIDE `topics` and `unfiled` — what the call did with the words — never instead of them. */
26497
+ said: external_exports.array(external_exports.object({
26498
+ lineId: external_exports.string().uuid(),
26499
+ speaker: external_exports.enum(["user", "agent"]),
26500
+ agent: external_exports.object({ id: external_exports.string().nullable(), name: external_exports.string().nullable() }).nullable(),
26501
+ words: external_exports.string(),
26502
+ atMs: external_exports.number()
26503
+ })),
26383
26504
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
26384
26505
  });
26385
26506
  TOKEN_PATH = join22(homedir22(), ".paigy", "token.json");
@@ -32071,7 +32192,8 @@ function startSession(opts) {
32071
32192
  if (!existsSync3(cwd)) {
32072
32193
  queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
32073
32194
  }
32074
- const codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
32195
+ let codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
32196
+ if (opts.model && opts.harness === "codex") codexConfig = { ...codexConfig, model: opts.model };
32075
32197
  if (codexConfig) {
32076
32198
  const key3 = "mcp_servers.paigy.env_vars";
32077
32199
  const configPath = join5(process.env.CODEX_HOME || join5(homedir4(), ".codex"), "config.toml");
@@ -32097,6 +32219,8 @@ function startSession(opts) {
32097
32219
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
32098
32220
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
32099
32221
  ...opts.instance ? { PAIGY_INSTANCE_ID: opts.instance } : {},
32222
+ ...opts.model && opts.harness === "claude" ? { CLAUDE_MODEL_CONFIG: JSON.stringify({ provider: "anthropic", model: opts.model }) } : {},
32223
+ ...opts.model && opts.harness === "agy" ? { AGY_MODEL_CONFIG: JSON.stringify({ model: opts.model }) } : {},
32100
32224
  ...codexConfig ? { CODEX_CONFIG: JSON.stringify(codexConfig) } : {},
32101
32225
  ...opts.session ?? opts.resume ? { PAIGY_SESSION_ID: opts.session ?? opts.resume } : {},
32102
32226
  // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
@@ -33601,8 +33725,41 @@ var UpdateInputSchema2 = external_exports.object({
33601
33725
  message: external_exports.string().trim().min(1).describe(`The one point, at most ${ASK_MAX2} characters; the context goes in units.`),
33602
33726
  goalIds: external_exports.array(external_exports.string().uuid()).min(1).max(10).describe("The Goals it is about, each one that exists: the one it is mainly about first."),
33603
33727
  units: units2,
33728
+ // A REPORT KEEPS ITS TAP (#2799, #3260; owner, 2026-10-08, settling #2776: "keep the reports open
33729
+ // until the user acknowledges them"). A report asks nothing, so it opens no Question -- but it
33730
+ // still waits to be acknowledged, and it carries the ONE tap that closes it. Never a decision: the
33731
+ // tap lands as a contribution, the person's own words on the Goal, because there is no Question
33732
+ // for it to answer.
33733
+ //
33734
+ // AND ITS WORDS ARE WRITTEN FOR THIS REPORT (owner, 2026-10-08, Goal 3c12d3c6: "Agent
33735
+ // acknowledgments vary (e.g. 'sounds good, I will test later') instead of always 'got it'"). `Got
33736
+ // it` and `Noted` say only that a card was cleared, where "Sounds good, I'll test later" says the
33737
+ // work was RECEIVED and not verified -- the distinction a session spent an hour recovering from a
33738
+ // call transcript on 2026-10-07, having read "not quite" as a defect report when it meant *not
33739
+ // tested yet*. The words are worth writing because the agent reads them back.
33740
+ //
33741
+ // ONE STRING, NOT A LIST (owner, 2026-10-08 15:47, Goal 31a1e2ac, asked whether an agent should
33742
+ // write up to four of them: "just on the cards, but it should be more like just they should only
33743
+ // get one basically string value that they can enter that replaces got it"). A report has exactly
33744
+ // one way to close, so the only thing an agent writes is what that button SAYS. #3260's 1..6
33745
+ // `options` and this field are one job: the list is deleted, not deprecated (ONE JOB, ONE
33746
+ // MECHANISM). It rides the door as the report's single stored option (`storedOption`,
33747
+ // `apps/api/src/goal/router.ts`), so no reader below the door learns a second shape.
33748
+ reply: external_exports.string().trim().min(1).describe(
33749
+ `What this report's button says instead of "Got it", at most ${OPTION_LABEL_MAX2} characters, written in THEIR voice for THIS report ("Sounds good, I'll test later") -- the care you give a question's options, so what they tap tells you whether they verified your work or only received it. One tap sends exactly these words back to you. Words that would CHANGE what happens next are a question, not a reply to a report: it still asks nothing and still owes you no answer. Left out, the button says "Got it", as every card did.`
33750
+ ).optional(),
33604
33751
  userExplicitlyRequested: userExplicitlyRequested2
33605
- }).strict().superRefine((u, ctx) => refuseOverCaps2(u.message, u.units, "message", ctx));
33752
+ }).strict().superRefine((u, ctx) => {
33753
+ refuseOverCaps2(u.message, u.units, "message", ctx);
33754
+ if (u.reply && u.reply.length > OPTION_LABEL_MAX2) {
33755
+ ctx.addIssue({
33756
+ code: external_exports.ZodIssueCode.custom,
33757
+ path: ["reply"],
33758
+ params: { refusal: "reply_too_long", length: u.reply.length, cap: OPTION_LABEL_MAX2 },
33759
+ message: `reply_too_long: the reply is ${u.reply.length} characters; the cap is ${OPTION_LABEL_MAX2}. It is a button's words, so say it in a breath.`
33760
+ });
33761
+ }
33762
+ });
33606
33763
  var questionFields2 = {
33607
33764
  question: external_exports.string().trim().min(1).describe(
33608
33765
  `ONE question, in at most ${ASK_MAX2} characters, and only what is needed to answer it; the context goes in units. Several questions are several objects, one each: an answer settles the one question it was given, so a person who answers the part of a bundled question that interested them settles nothing and is asked again. Separate questions cost nothing: ANY contact for a person already on a call JOINS that call, so several arrive as one call.`
@@ -33686,6 +33843,8 @@ QUESTIONS: one question per object; several questions are several objects in one
33686
33843
 
33687
33844
  CUT A LONG MESSAGE BEFORE YOU SEND IT: a question, an update's message or an answer is the one point, in at most ${ASK_MAX2} characters; everything else it needs goes in 'units', up to ${UNITS_MAX2} {title, body} in reading order, one idea each, every title at most ${UNIT_TITLE_MAX2} characters and every body at most ${UNIT_BODY_MAX2}. The person sees the main text and the titles, and opens a unit to read its body. Over a cap is refused by name (ask_too_long, too_many_units, unit_title_too_long, unit_body_too_long) and nothing is sent.
33688
33845
 
33846
+ GIVE A REPORT ITS REPLY: an update takes \`reply\` too, one line the person can tap instead of typing, written in THEIR voice for THAT report ("Sounds good, I'll test later") \u2014 the same care you give a question's options. It is what their button says instead of "Got it", and what they tap is what you read back, which tells you whether they verified your work or only received it. One line, because a report has one way to close, and it may not change what happens next: a reply that would is a question. Nothing is owed on it either way, and a report that writes none offers "Got it".
33847
+
33689
33848
  UPDATES ask nothing, so no answer is owed and none should be awaited. An update reaches the person only when they asked you for it (userExplicitlyRequested), when it answers something they said, or once its Goal is done; any other is recorded as the Goal's progress and nobody is notified. So when the work is finished, mark the Goal done first (manage_goals), then send one update saying what is done and anything they need to do or check. On a Goal whose report card is still open, an update that reaches them is added to that card, with no new push. If the person is already on a call, anything that reaches them joins that call, with no ring.
33690
33849
 
33691
33850
  RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of what is addressed to you (not a history page): \`question\`, a Question you owe (answer it in answers, with its questionId); \`update\`, something new on one of your Goals (a reply, an answer: read it with get_goal); \`instruction\`, a request or note sent to you. \`hasMore\` says more are waiting. Reading acknowledges nothing: once you have handled events, confirm their eventIds with contact({ackEventIds}), and the next batch can come. contact({}) waits up to about 45 seconds for something to arrive; contact({wait:false}) returns at once. It also lists work given to you that nobody has started (\`assigned\`; your first write to it starts it) and your work gone quiet (\`stalled\`).`;
@@ -34040,8 +34199,10 @@ var DISTILLER_DEFECTS2 = [
34040
34199
  // something the person answered or asked for did not save, was refused or dropped
34041
34200
  "ignored",
34042
34201
  // the person asked or said something and was never answered (the critic's ignored_a_question)
34043
- "promised"
34202
+ "promised",
34044
34203
  // said it did or would do something, and nothing shows it done (the critic's broken_promise)
34204
+ "dropped"
34205
+ // something it had to say never played and was never said later (owner, 2026-10-08, call 65433a9d)
34045
34206
  ];
34046
34207
  var DistillerReplySchema2 = external_exports.object({
34047
34208
  defects: external_exports.array(external_exports.object({
@@ -34089,6 +34250,8 @@ function entryWords(entry) {
34089
34250
  }
34090
34251
  var LIVE_MS2 = 3 * 6e4;
34091
34252
  var WORKING_MS2 = 60 * 6e4;
34253
+ var WORKING_TIER_MS2 = 2 * 60 * 6e4;
34254
+ var RECENT_TIER_MS2 = 24 * 60 * 6e4;
34092
34255
  var ContextSchema2 = external_exports.object({
34093
34256
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
34094
34257
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -34433,6 +34596,10 @@ var AgendaTurnSchema2 = external_exports.object({
34433
34596
  * so "who said that" survives the call, and it rides PER TURN because a coalesced call
34434
34597
  * speaks for several agents — the turn is the only place that knows which. */
34435
34598
  agentId: external_exports.string().optional(),
34599
+ /** ITS QUESTION IS ON THE TABLE (#3292): a talker line about a question the person owes. The walk tells
34600
+ * the phone (`asking`, so the agenda goes gold and its options show) without treating the line as a
34601
+ * question turn — the talker owns re-asking and the waits, so `question` stays null. */
34602
+ onTable: external_exports.boolean().optional(),
34436
34603
  select: SelectShapeSchema2.optional(),
34437
34604
  options: external_exports.array(OptionSchema2.omit({ id: true })).optional(),
34438
34605
  /* `pace` STOOD HERE (#826). A turn could carry seconds and the model chose them. The walk
@@ -34507,9 +34674,16 @@ var InboxItemSchema2 = external_exports.object({
34507
34674
  /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
34508
34675
  * its need offers, exactly as its own card carries them, present only when one of them has a
34509
34676
  * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
34510
- * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
34511
- * says those, and the list stays small (an `html` is up to 16 KB). */
34512
- options: external_exports.array(OptionSchema2).optional()
34677
+ * never re-sent as a second card to be seen mid-call. Words-only options are `choices`. */
34678
+ options: external_exports.array(OptionSchema2).optional(),
34679
+ /** ITS OPTIONS WHEN THEY ARE WORDS (owner, 2026-10-02, on a call: "whenever there are pre-made
34680
+ * options … now for a checklist, we should also show them on the screen"; design C, 2026-10-08):
34681
+ * numbered chips above the call's controls, so a checklist is never only something read aloud. A
34682
+ * field of its own so `options` keeps meaning "previews": a phone on an older bundle opens the
34683
+ * preview layout for any `options`, and must see exactly what it did. */
34684
+ choices: external_exports.array(OptionSchema2).optional(),
34685
+ /** HOW MANY MAY BE PICKED, beside its `choices`: one, any (`many`) or an order (`rank`). */
34686
+ select: external_exports.enum(["one", "many", "rank"]).optional()
34513
34687
  })).optional(),
34514
34688
  visuals: external_exports.array(VisualSchema2).optional(),
34515
34689
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -34946,7 +35120,11 @@ var QueueItemSchema2 = external_exports.object({
34946
35120
  from: external_exports.enum(["person", "agent"]),
34947
35121
  said: external_exports.string(),
34948
35122
  at: external_exports.string(),
34949
- entryId: external_exports.string()
35123
+ entryId: external_exports.string(),
35124
+ /** ITS SUMMARY IN AT MOST TEN WORDS (owner, 2026-10-08: "the same switch to the more summarized sources
35125
+ * at the work tab top level screen too"): what the talker or filer wrote on the Entry; absent when
35126
+ * none was. The Work row reads it before `said`. */
35127
+ summary: external_exports.string().optional()
34950
35128
  }).nullable().optional(),
34951
35129
  /** WHEN THIS PERSON LAST PUT A HAND ON IT THEMSELVES (owner, Paigy Goal 16d18f51, 2026-09-30):
34952
35130
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
@@ -35281,6 +35459,9 @@ var CallRecapSchema2 = external_exports.object({
35281
35459
  title: external_exports.string(),
35282
35460
  owner: external_exports.string(),
35283
35461
  state: external_exports.string(),
35462
+ /** Whether the call put it to the person. An item it carried and never said has no row on the
35463
+ * review (owner, 2026-10-08: "why do we get the third one when it didn't happen in the call?"). */
35464
+ presented: external_exports.boolean(),
35284
35465
  questions: external_exports.array(external_exports.object({ id: external_exports.string().uuid(), state: external_exports.string(), title: external_exports.string() })),
35285
35466
  /** `words` is always what they SAID, verbatim — the record, never replaced. `summary` is what
35286
35467
  * the line says in ten words (owner, 2026-10-07): the filer's, or the talker's for the answer it
@@ -35288,9 +35469,33 @@ var CallRecapSchema2 = external_exports.object({
35288
35469
  * neither wrote one. */
35289
35470
  /** `about` is the request the line answered (its question), null for words that answered none —
35290
35471
  * the key the screen groups on, so one question is one row however many times it was answered. */
35291
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), summary: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
35472
+ /** `reply` is the reply the line makes up (an answer, an ok, a deferral), so one reply is one row
35473
+ * however many lines it cites; `at` is the call lines it came from, so a filed line an answer already
35474
+ * said is not drawn again (owner, 2026-10-07: "fix them all"); `replyKind` says which replies are answers. */
35475
+ lines: external_exports.array(external_exports.object({
35476
+ entryId: external_exports.string().uuid(),
35477
+ words: external_exports.string(),
35478
+ summary: external_exports.string().optional(),
35479
+ about: external_exports.string().nullable().optional(),
35480
+ reply: external_exports.string().nullable().optional(),
35481
+ replyKind: external_exports.string().nullable().optional(),
35482
+ at: external_exports.array(external_exports.number()).optional()
35483
+ }))
35292
35484
  })),
35293
35485
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
35486
+ /** THE PUBLIC TRANSCRIPT — every line said on the call, in the order it was said, and WHO SAID IT
35487
+ * (owner, 2026-10-08: "the public view should only have the lines and who said them, and that's
35488
+ * it"; `docs/brain/phase-three/modular.md` build step 3). `speaker` is the role: "user" is the
35489
+ * person, "agent" an agent speaking, named in `agent`. The timeline's action lines are ours, not
35490
+ * theirs, and are not here; nor is a sealed line, which the server has no words for. It stands
35491
+ * BESIDE `topics` and `unfiled` — what the call did with the words — never instead of them. */
35492
+ said: external_exports.array(external_exports.object({
35493
+ lineId: external_exports.string().uuid(),
35494
+ speaker: external_exports.enum(["user", "agent"]),
35495
+ agent: external_exports.object({ id: external_exports.string().nullable(), name: external_exports.string().nullable() }).nullable(),
35496
+ words: external_exports.string(),
35497
+ atMs: external_exports.number()
35498
+ })),
35294
35499
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
35295
35500
  });
35296
35501
 
@@ -36245,10 +36450,14 @@ function localHolderPids(deps = {}) {
36245
36450
  if (!/^\d+\.json$/.test(file)) continue;
36246
36451
  let row;
36247
36452
  try {
36248
- row = JSON.parse(read3(join7(dir, file)));
36453
+ row = parseSessionRecord(read3(join7(dir, file)));
36249
36454
  } catch {
36250
36455
  continue;
36251
36456
  }
36457
+ if (!row) {
36458
+ deps.unreadable?.(file);
36459
+ continue;
36460
+ }
36252
36461
  const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
36253
36462
  if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
36254
36463
  if (!holds(pid, row.procStart, check)) continue;
@@ -36290,10 +36499,14 @@ function localHolders(deps = {}) {
36290
36499
  if (!/^\d+\.json$/.test(file)) continue;
36291
36500
  let row;
36292
36501
  try {
36293
- row = JSON.parse(read3(join7(dir, file)));
36502
+ row = parseSessionRecord(read3(join7(dir, file)));
36294
36503
  } catch {
36295
36504
  continue;
36296
36505
  }
36506
+ if (!row) {
36507
+ deps.unreadable?.(file);
36508
+ continue;
36509
+ }
36297
36510
  const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
36298
36511
  if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
36299
36512
  const slot = sessionSlot(row.sessionId);
@@ -36514,7 +36727,7 @@ var claimedRun = (sessionId2) => {
36514
36727
  const slot = sessionSlot(sessionId2);
36515
36728
  return { slot, key: runKey(slot) };
36516
36729
  };
36517
- var launchRun = (spec) => ({ session: spec.sessionId });
36730
+ var launchRun = (spec) => ({ session: spec.sessionId, model: spec.model });
36518
36731
  var BOUND_ELSEWHERE_MS = 10 * 6e4;
36519
36732
  var boundElsewhere = /* @__PURE__ */ new Map();
36520
36733
  var BOUND_SKIP = "is bound to the session it was paired in, which this host cannot name \u2014 run `paigy-harness handoff` as it, in its folder, to record it";
@@ -36623,7 +36836,7 @@ function startHost(opts) {
36623
36836
  });
36624
36837
  void heartbeat({
36625
36838
  version: VERSION,
36626
- harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
36839
+ harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status, models: a.models })),
36627
36840
  workspaces: listWorkspaces(opts.wsDeps),
36628
36841
  // The repos inside them, so a session started for work on one opens IN it (Goal 26982211).
36629
36842
  repos: listRepos(opts.wsDeps)
@@ -36715,9 +36928,14 @@ function startHost(opts) {
36715
36928
  let lastPresence = /* @__PURE__ */ new Map();
36716
36929
  async function evidence() {
36717
36930
  const presence = await presenceMap();
36718
- lastHolders = localHolders();
36931
+ lastHolders = localHolders({ unreadable: (file) => {
36932
+ if (unreadable.has(file)) return;
36933
+ unreadable.add(file);
36934
+ opts.log(`\u25CC session record ${file} has no object in it \u2014 the process it names reads as not running here`);
36935
+ } });
36719
36936
  return { presence, holders: lastHolders };
36720
36937
  }
36938
+ const unreadable = /* @__PURE__ */ new Set();
36721
36939
  let lastHolders = /* @__PURE__ */ new Map();
36722
36940
  async function wakeSlot(slot, { presence, holders }, cause = "sweep") {
36723
36941
  {
package/dist/main.js CHANGED
@@ -8680,10 +8680,47 @@ function claudeConversation() {
8680
8680
  if (!envSession("CLAUDE_CODE_SESSION_ID") || process.env.CLAUDE_CODE_CHILD_SESSION) return void 0;
8681
8681
  const dir = envSession("CLAUDE_CONFIG_DIR") ?? join(homedir(), ".claude");
8682
8682
  for (const pid of ancestors()) {
8683
+ let text;
8683
8684
  try {
8684
- const { sessionId: sessionId2 } = JSON.parse(readFileSync(join(dir, "sessions", `${pid}.json`), "utf8"));
8685
- return typeof sessionId2 === "string" && sessionId2.trim() ? sessionId2 : void 0;
8685
+ text = readFileSync(join(dir, "sessions", `${pid}.json`), "utf8");
8686
8686
  } catch {
8687
+ continue;
8688
+ }
8689
+ const record = parseSessionRecord(text);
8690
+ if (!record) continue;
8691
+ return typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId : void 0;
8692
+ }
8693
+ return void 0;
8694
+ }
8695
+ function parseSessionRecord(text) {
8696
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
8697
+ try {
8698
+ return object(JSON.parse(text));
8699
+ } catch {
8700
+ }
8701
+ let depth = 0;
8702
+ let start = -1;
8703
+ let quoted = false;
8704
+ let escaped = false;
8705
+ for (let i = 0; i < text.length; i += 1) {
8706
+ const c = text[i];
8707
+ if (quoted) {
8708
+ if (escaped) escaped = false;
8709
+ else if (c === "\\") escaped = true;
8710
+ else if (c === '"') quoted = false;
8711
+ } else if (c === '"') quoted = true;
8712
+ else if (c === "{") {
8713
+ if (depth === 0) start = i;
8714
+ depth += 1;
8715
+ } else if (c === "}" && depth > 0) {
8716
+ depth -= 1;
8717
+ if (depth === 0) {
8718
+ try {
8719
+ return object(JSON.parse(text.slice(start, i + 1)));
8720
+ } catch {
8721
+ return void 0;
8722
+ }
8723
+ }
8687
8724
  }
8688
8725
  }
8689
8726
  return void 0;
@@ -10047,8 +10084,41 @@ var UpdateInputSchema = external_exports.object({
10047
10084
  message: external_exports.string().trim().min(1).describe(`The one point, at most ${ASK_MAX} characters; the context goes in units.`),
10048
10085
  goalIds: external_exports.array(external_exports.string().uuid()).min(1).max(10).describe("The Goals it is about, each one that exists: the one it is mainly about first."),
10049
10086
  units,
10087
+ // A REPORT KEEPS ITS TAP (#2799, #3260; owner, 2026-10-08, settling #2776: "keep the reports open
10088
+ // until the user acknowledges them"). A report asks nothing, so it opens no Question -- but it
10089
+ // still waits to be acknowledged, and it carries the ONE tap that closes it. Never a decision: the
10090
+ // tap lands as a contribution, the person's own words on the Goal, because there is no Question
10091
+ // for it to answer.
10092
+ //
10093
+ // AND ITS WORDS ARE WRITTEN FOR THIS REPORT (owner, 2026-10-08, Goal 3c12d3c6: "Agent
10094
+ // acknowledgments vary (e.g. 'sounds good, I will test later') instead of always 'got it'"). `Got
10095
+ // it` and `Noted` say only that a card was cleared, where "Sounds good, I'll test later" says the
10096
+ // work was RECEIVED and not verified -- the distinction a session spent an hour recovering from a
10097
+ // call transcript on 2026-10-07, having read "not quite" as a defect report when it meant *not
10098
+ // tested yet*. The words are worth writing because the agent reads them back.
10099
+ //
10100
+ // ONE STRING, NOT A LIST (owner, 2026-10-08 15:47, Goal 31a1e2ac, asked whether an agent should
10101
+ // write up to four of them: "just on the cards, but it should be more like just they should only
10102
+ // get one basically string value that they can enter that replaces got it"). A report has exactly
10103
+ // one way to close, so the only thing an agent writes is what that button SAYS. #3260's 1..6
10104
+ // `options` and this field are one job: the list is deleted, not deprecated (ONE JOB, ONE
10105
+ // MECHANISM). It rides the door as the report's single stored option (`storedOption`,
10106
+ // `apps/api/src/goal/router.ts`), so no reader below the door learns a second shape.
10107
+ reply: external_exports.string().trim().min(1).describe(
10108
+ `What this report's button says instead of "Got it", at most ${OPTION_LABEL_MAX} characters, written in THEIR voice for THIS report ("Sounds good, I'll test later") -- the care you give a question's options, so what they tap tells you whether they verified your work or only received it. One tap sends exactly these words back to you. Words that would CHANGE what happens next are a question, not a reply to a report: it still asks nothing and still owes you no answer. Left out, the button says "Got it", as every card did.`
10109
+ ).optional(),
10050
10110
  userExplicitlyRequested
10051
- }).strict().superRefine((u, ctx) => refuseOverCaps(u.message, u.units, "message", ctx));
10111
+ }).strict().superRefine((u, ctx) => {
10112
+ refuseOverCaps(u.message, u.units, "message", ctx);
10113
+ if (u.reply && u.reply.length > OPTION_LABEL_MAX) {
10114
+ ctx.addIssue({
10115
+ code: external_exports.ZodIssueCode.custom,
10116
+ path: ["reply"],
10117
+ params: { refusal: "reply_too_long", length: u.reply.length, cap: OPTION_LABEL_MAX },
10118
+ message: `reply_too_long: the reply is ${u.reply.length} characters; the cap is ${OPTION_LABEL_MAX}. It is a button's words, so say it in a breath.`
10119
+ });
10120
+ }
10121
+ });
10052
10122
  var questionFields = {
10053
10123
  question: external_exports.string().trim().min(1).describe(
10054
10124
  `ONE question, in at most ${ASK_MAX} characters, and only what is needed to answer it; the context goes in units. Several questions are several objects, one each: an answer settles the one question it was given, so a person who answers the part of a bundled question that interested them settles nothing and is asked again. Separate questions cost nothing: ANY contact for a person already on a call JOINS that call, so several arrive as one call.`
@@ -10132,6 +10202,8 @@ QUESTIONS: one question per object; several questions are several objects in one
10132
10202
 
10133
10203
  CUT A LONG MESSAGE BEFORE YOU SEND IT: a question, an update's message or an answer is the one point, in at most ${ASK_MAX} characters; everything else it needs goes in 'units', up to ${UNITS_MAX} {title, body} in reading order, one idea each, every title at most ${UNIT_TITLE_MAX} characters and every body at most ${UNIT_BODY_MAX}. The person sees the main text and the titles, and opens a unit to read its body. Over a cap is refused by name (ask_too_long, too_many_units, unit_title_too_long, unit_body_too_long) and nothing is sent.
10134
10204
 
10205
+ GIVE A REPORT ITS REPLY: an update takes \`reply\` too, one line the person can tap instead of typing, written in THEIR voice for THAT report ("Sounds good, I'll test later") \u2014 the same care you give a question's options. It is what their button says instead of "Got it", and what they tap is what you read back, which tells you whether they verified your work or only received it. One line, because a report has one way to close, and it may not change what happens next: a reply that would is a question. Nothing is owed on it either way, and a report that writes none offers "Got it".
10206
+
10135
10207
  UPDATES ask nothing, so no answer is owed and none should be awaited. An update reaches the person only when they asked you for it (userExplicitlyRequested), when it answers something they said, or once its Goal is done; any other is recorded as the Goal's progress and nobody is notified. So when the work is finished, mark the Goal done first (manage_goals), then send one update saying what is done and anything they need to do or check. On a Goal whose report card is still open, an update that reaches them is added to that card, with no new push. If the person is already on a call, anything that reaches them joins that call, with no ring.
10136
10208
 
10137
10209
  RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of what is addressed to you (not a history page): \`question\`, a Question you owe (answer it in answers, with its questionId); \`update\`, something new on one of your Goals (a reply, an answer: read it with get_goal); \`instruction\`, a request or note sent to you. \`hasMore\` says more are waiting. Reading acknowledges nothing: once you have handled events, confirm their eventIds with contact({ackEventIds}), and the next batch can come. contact({}) waits up to about 45 seconds for something to arrive; contact({wait:false}) returns at once. It also lists work given to you that nobody has started (\`assigned\`; your first write to it starts it) and your work gone quiet (\`stalled\`).`;
@@ -10486,8 +10558,10 @@ var DISTILLER_DEFECTS = [
10486
10558
  // something the person answered or asked for did not save, was refused or dropped
10487
10559
  "ignored",
10488
10560
  // the person asked or said something and was never answered (the critic's ignored_a_question)
10489
- "promised"
10561
+ "promised",
10490
10562
  // said it did or would do something, and nothing shows it done (the critic's broken_promise)
10563
+ "dropped"
10564
+ // something it had to say never played and was never said later (owner, 2026-10-08, call 65433a9d)
10491
10565
  ];
10492
10566
  var DistillerReplySchema = external_exports.object({
10493
10567
  defects: external_exports.array(external_exports.object({
@@ -10525,6 +10599,8 @@ function entryUnit(entry) {
10525
10599
  }
10526
10600
  var LIVE_MS = 3 * 6e4;
10527
10601
  var WORKING_MS = 60 * 6e4;
10602
+ var WORKING_TIER_MS = 2 * 60 * 6e4;
10603
+ var RECENT_TIER_MS = 24 * 60 * 6e4;
10528
10604
  var ContextSchema = external_exports.object({
10529
10605
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
10530
10606
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -10869,6 +10945,10 @@ var AgendaTurnSchema = external_exports.object({
10869
10945
  * so "who said that" survives the call, and it rides PER TURN because a coalesced call
10870
10946
  * speaks for several agents — the turn is the only place that knows which. */
10871
10947
  agentId: external_exports.string().optional(),
10948
+ /** ITS QUESTION IS ON THE TABLE (#3292): a talker line about a question the person owes. The walk tells
10949
+ * the phone (`asking`, so the agenda goes gold and its options show) without treating the line as a
10950
+ * question turn — the talker owns re-asking and the waits, so `question` stays null. */
10951
+ onTable: external_exports.boolean().optional(),
10872
10952
  select: SelectShapeSchema.optional(),
10873
10953
  options: external_exports.array(OptionSchema.omit({ id: true })).optional(),
10874
10954
  /* `pace` STOOD HERE (#826). A turn could carry seconds and the model chose them. The walk
@@ -10943,9 +11023,16 @@ var InboxItemSchema = external_exports.object({
10943
11023
  /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
10944
11024
  * its need offers, exactly as its own card carries them, present only when one of them has a
10945
11025
  * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
10946
- * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
10947
- * says those, and the list stays small (an `html` is up to 16 KB). */
10948
- options: external_exports.array(OptionSchema).optional()
11026
+ * never re-sent as a second card to be seen mid-call. Words-only options are `choices`. */
11027
+ options: external_exports.array(OptionSchema).optional(),
11028
+ /** ITS OPTIONS WHEN THEY ARE WORDS (owner, 2026-10-02, on a call: "whenever there are pre-made
11029
+ * options … now for a checklist, we should also show them on the screen"; design C, 2026-10-08):
11030
+ * numbered chips above the call's controls, so a checklist is never only something read aloud. A
11031
+ * field of its own so `options` keeps meaning "previews": a phone on an older bundle opens the
11032
+ * preview layout for any `options`, and must see exactly what it did. */
11033
+ choices: external_exports.array(OptionSchema).optional(),
11034
+ /** HOW MANY MAY BE PICKED, beside its `choices`: one, any (`many`) or an order (`rank`). */
11035
+ select: external_exports.enum(["one", "many", "rank"]).optional()
10949
11036
  })).optional(),
10950
11037
  visuals: external_exports.array(VisualSchema).optional(),
10951
11038
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -11382,7 +11469,11 @@ var QueueItemSchema = external_exports.object({
11382
11469
  from: external_exports.enum(["person", "agent"]),
11383
11470
  said: external_exports.string(),
11384
11471
  at: external_exports.string(),
11385
- entryId: external_exports.string()
11472
+ entryId: external_exports.string(),
11473
+ /** ITS SUMMARY IN AT MOST TEN WORDS (owner, 2026-10-08: "the same switch to the more summarized sources
11474
+ * at the work tab top level screen too"): what the talker or filer wrote on the Entry; absent when
11475
+ * none was. The Work row reads it before `said`. */
11476
+ summary: external_exports.string().optional()
11386
11477
  }).nullable().optional(),
11387
11478
  /** WHEN THIS PERSON LAST PUT A HAND ON IT THEMSELVES (owner, Paigy Goal 16d18f51, 2026-09-30):
11388
11479
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
@@ -11719,6 +11810,9 @@ var CallRecapSchema = external_exports.object({
11719
11810
  title: external_exports.string(),
11720
11811
  owner: external_exports.string(),
11721
11812
  state: external_exports.string(),
11813
+ /** Whether the call put it to the person. An item it carried and never said has no row on the
11814
+ * review (owner, 2026-10-08: "why do we get the third one when it didn't happen in the call?"). */
11815
+ presented: external_exports.boolean(),
11722
11816
  questions: external_exports.array(external_exports.object({ id: external_exports.string().uuid(), state: external_exports.string(), title: external_exports.string() })),
11723
11817
  /** `words` is always what they SAID, verbatim — the record, never replaced. `summary` is what
11724
11818
  * the line says in ten words (owner, 2026-10-07): the filer's, or the talker's for the answer it
@@ -11726,9 +11820,33 @@ var CallRecapSchema = external_exports.object({
11726
11820
  * neither wrote one. */
11727
11821
  /** `about` is the request the line answered (its question), null for words that answered none —
11728
11822
  * the key the screen groups on, so one question is one row however many times it was answered. */
11729
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), summary: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
11823
+ /** `reply` is the reply the line makes up (an answer, an ok, a deferral), so one reply is one row
11824
+ * however many lines it cites; `at` is the call lines it came from, so a filed line an answer already
11825
+ * said is not drawn again (owner, 2026-10-07: "fix them all"); `replyKind` says which replies are answers. */
11826
+ lines: external_exports.array(external_exports.object({
11827
+ entryId: external_exports.string().uuid(),
11828
+ words: external_exports.string(),
11829
+ summary: external_exports.string().optional(),
11830
+ about: external_exports.string().nullable().optional(),
11831
+ reply: external_exports.string().nullable().optional(),
11832
+ replyKind: external_exports.string().nullable().optional(),
11833
+ at: external_exports.array(external_exports.number()).optional()
11834
+ }))
11730
11835
  })),
11731
11836
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
11837
+ /** THE PUBLIC TRANSCRIPT — every line said on the call, in the order it was said, and WHO SAID IT
11838
+ * (owner, 2026-10-08: "the public view should only have the lines and who said them, and that's
11839
+ * it"; `docs/brain/phase-three/modular.md` build step 3). `speaker` is the role: "user" is the
11840
+ * person, "agent" an agent speaking, named in `agent`. The timeline's action lines are ours, not
11841
+ * theirs, and are not here; nor is a sealed line, which the server has no words for. It stands
11842
+ * BESIDE `topics` and `unfiled` — what the call did with the words — never instead of them. */
11843
+ said: external_exports.array(external_exports.object({
11844
+ lineId: external_exports.string().uuid(),
11845
+ speaker: external_exports.enum(["user", "agent"]),
11846
+ agent: external_exports.object({ id: external_exports.string().nullable(), name: external_exports.string().nullable() }).nullable(),
11847
+ words: external_exports.string(),
11848
+ atMs: external_exports.number()
11849
+ })),
11732
11850
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
11733
11851
  });
11734
11852
  function sessionSlot(sessionId2) {
@@ -12283,7 +12401,8 @@ var CATALOG = [
12283
12401
  authProbe: ["claude", "auth", "status"],
12284
12402
  loginHint: "Run `claude` once in a terminal and sign in.",
12285
12403
  installCli: "curl -fsSL https://claude.ai/install.sh | bash",
12286
- installAdapter: "npm install -g @agentclientprotocol/claude-agent-acp"
12404
+ installAdapter: "npm install -g @agentclientprotocol/claude-agent-acp",
12405
+ models: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229"]
12287
12406
  },
12288
12407
  {
12289
12408
  name: "codex",
@@ -12293,7 +12412,8 @@ var CATALOG = [
12293
12412
  authProbe: ["codex", "login", "status"],
12294
12413
  loginHint: "Run `codex login` to authenticate.",
12295
12414
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
12296
- installAdapter: "npm install -g @agentclientprotocol/codex-acp"
12415
+ installAdapter: "npm install -g @agentclientprotocol/codex-acp",
12416
+ models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"]
12297
12417
  },
12298
12418
  {
12299
12419
  name: "agy",
@@ -12303,7 +12423,8 @@ var CATALOG = [
12303
12423
  adapter: "paigy-agy-acp",
12304
12424
  authProbe: ["agy", "help"],
12305
12425
  loginHint: "Install and set up Antigravity (`agy`).",
12306
- installCli: "curl -fsSL https://antigravity.dev/install.sh | bash"
12426
+ installCli: "curl -fsSL https://antigravity.dev/install.sh | bash",
12427
+ models: ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-pro-exp"]
12307
12428
  }
12308
12429
  ];
12309
12430
  function probeDirs(home = homedir3()) {
@@ -12339,7 +12460,7 @@ function runProbe(argv) {
12339
12460
  function detect(entry, deps = {}) {
12340
12461
  const find = deps.which ?? which;
12341
12462
  const probe = deps.probe ?? runProbe;
12342
- const base = { name: entry.name, label: entry.label };
12463
+ const base = { name: entry.name, label: entry.label, models: entry.models };
12343
12464
  const cliPath = find(entry.cli);
12344
12465
  if (!cliPath) return { ...base, status: "missing", hint: `${entry.label} is not installed.`, install: entry.installCli };
12345
12466
  const adapterPath = entry.adapter ? find(entry.adapter) : null;
@@ -13059,10 +13180,14 @@ function localHolderPids(deps = {}) {
13059
13180
  if (!/^\d+\.json$/.test(file)) continue;
13060
13181
  let row;
13061
13182
  try {
13062
- row = JSON.parse(read3(join6(dir, file)));
13183
+ row = parseSessionRecord(read3(join6(dir, file)));
13063
13184
  } catch {
13064
13185
  continue;
13065
13186
  }
13187
+ if (!row) {
13188
+ deps.unreadable?.(file);
13189
+ continue;
13190
+ }
13066
13191
  const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
13067
13192
  if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
13068
13193
  if (!holds(pid, row.procStart, check)) continue;
@@ -13104,10 +13229,14 @@ function localHolders(deps = {}) {
13104
13229
  if (!/^\d+\.json$/.test(file)) continue;
13105
13230
  let row;
13106
13231
  try {
13107
- row = JSON.parse(read3(join6(dir, file)));
13232
+ row = parseSessionRecord(read3(join6(dir, file)));
13108
13233
  } catch {
13109
13234
  continue;
13110
13235
  }
13236
+ if (!row) {
13237
+ deps.unreadable?.(file);
13238
+ continue;
13239
+ }
13111
13240
  const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
13112
13241
  if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
13113
13242
  const slot = sessionSlot(row.sessionId);
@@ -14643,8 +14772,41 @@ var UpdateInputSchema2 = external_exports.object({
14643
14772
  message: external_exports.string().trim().min(1).describe(`The one point, at most ${ASK_MAX2} characters; the context goes in units.`),
14644
14773
  goalIds: external_exports.array(external_exports.string().uuid()).min(1).max(10).describe("The Goals it is about, each one that exists: the one it is mainly about first."),
14645
14774
  units: units2,
14775
+ // A REPORT KEEPS ITS TAP (#2799, #3260; owner, 2026-10-08, settling #2776: "keep the reports open
14776
+ // until the user acknowledges them"). A report asks nothing, so it opens no Question -- but it
14777
+ // still waits to be acknowledged, and it carries the ONE tap that closes it. Never a decision: the
14778
+ // tap lands as a contribution, the person's own words on the Goal, because there is no Question
14779
+ // for it to answer.
14780
+ //
14781
+ // AND ITS WORDS ARE WRITTEN FOR THIS REPORT (owner, 2026-10-08, Goal 3c12d3c6: "Agent
14782
+ // acknowledgments vary (e.g. 'sounds good, I will test later') instead of always 'got it'"). `Got
14783
+ // it` and `Noted` say only that a card was cleared, where "Sounds good, I'll test later" says the
14784
+ // work was RECEIVED and not verified -- the distinction a session spent an hour recovering from a
14785
+ // call transcript on 2026-10-07, having read "not quite" as a defect report when it meant *not
14786
+ // tested yet*. The words are worth writing because the agent reads them back.
14787
+ //
14788
+ // ONE STRING, NOT A LIST (owner, 2026-10-08 15:47, Goal 31a1e2ac, asked whether an agent should
14789
+ // write up to four of them: "just on the cards, but it should be more like just they should only
14790
+ // get one basically string value that they can enter that replaces got it"). A report has exactly
14791
+ // one way to close, so the only thing an agent writes is what that button SAYS. #3260's 1..6
14792
+ // `options` and this field are one job: the list is deleted, not deprecated (ONE JOB, ONE
14793
+ // MECHANISM). It rides the door as the report's single stored option (`storedOption`,
14794
+ // `apps/api/src/goal/router.ts`), so no reader below the door learns a second shape.
14795
+ reply: external_exports.string().trim().min(1).describe(
14796
+ `What this report's button says instead of "Got it", at most ${OPTION_LABEL_MAX2} characters, written in THEIR voice for THIS report ("Sounds good, I'll test later") -- the care you give a question's options, so what they tap tells you whether they verified your work or only received it. One tap sends exactly these words back to you. Words that would CHANGE what happens next are a question, not a reply to a report: it still asks nothing and still owes you no answer. Left out, the button says "Got it", as every card did.`
14797
+ ).optional(),
14646
14798
  userExplicitlyRequested: userExplicitlyRequested2
14647
- }).strict().superRefine((u, ctx) => refuseOverCaps2(u.message, u.units, "message", ctx));
14799
+ }).strict().superRefine((u, ctx) => {
14800
+ refuseOverCaps2(u.message, u.units, "message", ctx);
14801
+ if (u.reply && u.reply.length > OPTION_LABEL_MAX2) {
14802
+ ctx.addIssue({
14803
+ code: external_exports.ZodIssueCode.custom,
14804
+ path: ["reply"],
14805
+ params: { refusal: "reply_too_long", length: u.reply.length, cap: OPTION_LABEL_MAX2 },
14806
+ message: `reply_too_long: the reply is ${u.reply.length} characters; the cap is ${OPTION_LABEL_MAX2}. It is a button's words, so say it in a breath.`
14807
+ });
14808
+ }
14809
+ });
14648
14810
  var questionFields2 = {
14649
14811
  question: external_exports.string().trim().min(1).describe(
14650
14812
  `ONE question, in at most ${ASK_MAX2} characters, and only what is needed to answer it; the context goes in units. Several questions are several objects, one each: an answer settles the one question it was given, so a person who answers the part of a bundled question that interested them settles nothing and is asked again. Separate questions cost nothing: ANY contact for a person already on a call JOINS that call, so several arrive as one call.`
@@ -14728,6 +14890,8 @@ QUESTIONS: one question per object; several questions are several objects in one
14728
14890
 
14729
14891
  CUT A LONG MESSAGE BEFORE YOU SEND IT: a question, an update's message or an answer is the one point, in at most ${ASK_MAX2} characters; everything else it needs goes in 'units', up to ${UNITS_MAX2} {title, body} in reading order, one idea each, every title at most ${UNIT_TITLE_MAX2} characters and every body at most ${UNIT_BODY_MAX2}. The person sees the main text and the titles, and opens a unit to read its body. Over a cap is refused by name (ask_too_long, too_many_units, unit_title_too_long, unit_body_too_long) and nothing is sent.
14730
14892
 
14893
+ GIVE A REPORT ITS REPLY: an update takes \`reply\` too, one line the person can tap instead of typing, written in THEIR voice for THAT report ("Sounds good, I'll test later") \u2014 the same care you give a question's options. It is what their button says instead of "Got it", and what they tap is what you read back, which tells you whether they verified your work or only received it. One line, because a report has one way to close, and it may not change what happens next: a reply that would is a question. Nothing is owed on it either way, and a report that writes none offers "Got it".
14894
+
14731
14895
  UPDATES ask nothing, so no answer is owed and none should be awaited. An update reaches the person only when they asked you for it (userExplicitlyRequested), when it answers something they said, or once its Goal is done; any other is recorded as the Goal's progress and nobody is notified. So when the work is finished, mark the Goal done first (manage_goals), then send one update saying what is done and anything they need to do or check. On a Goal whose report card is still open, an update that reaches them is added to that card, with no new push. If the person is already on a call, anything that reaches them joins that call, with no ring.
14732
14896
 
14733
14897
  RECEIVING: a contact that sends nothing returns \`events\`, a limited batch of what is addressed to you (not a history page): \`question\`, a Question you owe (answer it in answers, with its questionId); \`update\`, something new on one of your Goals (a reply, an answer: read it with get_goal); \`instruction\`, a request or note sent to you. \`hasMore\` says more are waiting. Reading acknowledges nothing: once you have handled events, confirm their eventIds with contact({ackEventIds}), and the next batch can come. contact({}) waits up to about 45 seconds for something to arrive; contact({wait:false}) returns at once. It also lists work given to you that nobody has started (\`assigned\`; your first write to it starts it) and your work gone quiet (\`stalled\`).`;
@@ -15082,8 +15246,10 @@ var DISTILLER_DEFECTS2 = [
15082
15246
  // something the person answered or asked for did not save, was refused or dropped
15083
15247
  "ignored",
15084
15248
  // the person asked or said something and was never answered (the critic's ignored_a_question)
15085
- "promised"
15249
+ "promised",
15086
15250
  // said it did or would do something, and nothing shows it done (the critic's broken_promise)
15251
+ "dropped"
15252
+ // something it had to say never played and was never said later (owner, 2026-10-08, call 65433a9d)
15087
15253
  ];
15088
15254
  var DistillerReplySchema2 = external_exports.object({
15089
15255
  defects: external_exports.array(external_exports.object({
@@ -15131,6 +15297,8 @@ function entryWords(entry) {
15131
15297
  }
15132
15298
  var LIVE_MS2 = 3 * 6e4;
15133
15299
  var WORKING_MS2 = 60 * 6e4;
15300
+ var WORKING_TIER_MS2 = 2 * 60 * 6e4;
15301
+ var RECENT_TIER_MS2 = 24 * 60 * 6e4;
15134
15302
  var ContextSchema2 = external_exports.object({
15135
15303
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
15136
15304
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -15475,6 +15643,10 @@ var AgendaTurnSchema2 = external_exports.object({
15475
15643
  * so "who said that" survives the call, and it rides PER TURN because a coalesced call
15476
15644
  * speaks for several agents — the turn is the only place that knows which. */
15477
15645
  agentId: external_exports.string().optional(),
15646
+ /** ITS QUESTION IS ON THE TABLE (#3292): a talker line about a question the person owes. The walk tells
15647
+ * the phone (`asking`, so the agenda goes gold and its options show) without treating the line as a
15648
+ * question turn — the talker owns re-asking and the waits, so `question` stays null. */
15649
+ onTable: external_exports.boolean().optional(),
15478
15650
  select: SelectShapeSchema2.optional(),
15479
15651
  options: external_exports.array(OptionSchema2.omit({ id: true })).optional(),
15480
15652
  /* `pace` STOOD HERE (#826). A turn could carry seconds and the model chose them. The walk
@@ -15549,9 +15721,16 @@ var InboxItemSchema2 = external_exports.object({
15549
15721
  /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
15550
15722
  * its need offers, exactly as its own card carries them, present only when one of them has a
15551
15723
  * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
15552
- * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
15553
- * says those, and the list stays small (an `html` is up to 16 KB). */
15554
- options: external_exports.array(OptionSchema2).optional()
15724
+ * never re-sent as a second card to be seen mid-call. Words-only options are `choices`. */
15725
+ options: external_exports.array(OptionSchema2).optional(),
15726
+ /** ITS OPTIONS WHEN THEY ARE WORDS (owner, 2026-10-02, on a call: "whenever there are pre-made
15727
+ * options … now for a checklist, we should also show them on the screen"; design C, 2026-10-08):
15728
+ * numbered chips above the call's controls, so a checklist is never only something read aloud. A
15729
+ * field of its own so `options` keeps meaning "previews": a phone on an older bundle opens the
15730
+ * preview layout for any `options`, and must see exactly what it did. */
15731
+ choices: external_exports.array(OptionSchema2).optional(),
15732
+ /** HOW MANY MAY BE PICKED, beside its `choices`: one, any (`many`) or an order (`rank`). */
15733
+ select: external_exports.enum(["one", "many", "rank"]).optional()
15555
15734
  })).optional(),
15556
15735
  visuals: external_exports.array(VisualSchema2).optional(),
15557
15736
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -15988,7 +16167,11 @@ var QueueItemSchema2 = external_exports.object({
15988
16167
  from: external_exports.enum(["person", "agent"]),
15989
16168
  said: external_exports.string(),
15990
16169
  at: external_exports.string(),
15991
- entryId: external_exports.string()
16170
+ entryId: external_exports.string(),
16171
+ /** ITS SUMMARY IN AT MOST TEN WORDS (owner, 2026-10-08: "the same switch to the more summarized sources
16172
+ * at the work tab top level screen too"): what the talker or filer wrote on the Entry; absent when
16173
+ * none was. The Work row reads it before `said`. */
16174
+ summary: external_exports.string().optional()
15992
16175
  }).nullable().optional(),
15993
16176
  /** WHEN THIS PERSON LAST PUT A HAND ON IT THEMSELVES (owner, Paigy Goal 16d18f51, 2026-09-30):
15994
16177
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
@@ -16323,6 +16506,9 @@ var CallRecapSchema2 = external_exports.object({
16323
16506
  title: external_exports.string(),
16324
16507
  owner: external_exports.string(),
16325
16508
  state: external_exports.string(),
16509
+ /** Whether the call put it to the person. An item it carried and never said has no row on the
16510
+ * review (owner, 2026-10-08: "why do we get the third one when it didn't happen in the call?"). */
16511
+ presented: external_exports.boolean(),
16326
16512
  questions: external_exports.array(external_exports.object({ id: external_exports.string().uuid(), state: external_exports.string(), title: external_exports.string() })),
16327
16513
  /** `words` is always what they SAID, verbatim — the record, never replaced. `summary` is what
16328
16514
  * the line says in ten words (owner, 2026-10-07): the filer's, or the talker's for the answer it
@@ -16330,9 +16516,33 @@ var CallRecapSchema2 = external_exports.object({
16330
16516
  * neither wrote one. */
16331
16517
  /** `about` is the request the line answered (its question), null for words that answered none —
16332
16518
  * the key the screen groups on, so one question is one row however many times it was answered. */
16333
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), summary: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
16519
+ /** `reply` is the reply the line makes up (an answer, an ok, a deferral), so one reply is one row
16520
+ * however many lines it cites; `at` is the call lines it came from, so a filed line an answer already
16521
+ * said is not drawn again (owner, 2026-10-07: "fix them all"); `replyKind` says which replies are answers. */
16522
+ lines: external_exports.array(external_exports.object({
16523
+ entryId: external_exports.string().uuid(),
16524
+ words: external_exports.string(),
16525
+ summary: external_exports.string().optional(),
16526
+ about: external_exports.string().nullable().optional(),
16527
+ reply: external_exports.string().nullable().optional(),
16528
+ replyKind: external_exports.string().nullable().optional(),
16529
+ at: external_exports.array(external_exports.number()).optional()
16530
+ }))
16334
16531
  })),
16335
16532
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
16533
+ /** THE PUBLIC TRANSCRIPT — every line said on the call, in the order it was said, and WHO SAID IT
16534
+ * (owner, 2026-10-08: "the public view should only have the lines and who said them, and that's
16535
+ * it"; `docs/brain/phase-three/modular.md` build step 3). `speaker` is the role: "user" is the
16536
+ * person, "agent" an agent speaking, named in `agent`. The timeline's action lines are ours, not
16537
+ * theirs, and are not here; nor is a sealed line, which the server has no words for. It stands
16538
+ * BESIDE `topics` and `unfiled` — what the call did with the words — never instead of them. */
16539
+ said: external_exports.array(external_exports.object({
16540
+ lineId: external_exports.string().uuid(),
16541
+ speaker: external_exports.enum(["user", "agent"]),
16542
+ agent: external_exports.object({ id: external_exports.string().nullable(), name: external_exports.string().nullable() }).nullable(),
16543
+ words: external_exports.string(),
16544
+ atMs: external_exports.number()
16545
+ })),
16336
16546
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
16337
16547
  });
16338
16548
 
@@ -16734,7 +16944,8 @@ function startSession(opts) {
16734
16944
  if (!existsSync4(cwd)) {
16735
16945
  queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
16736
16946
  }
16737
- const codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
16947
+ let codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
16948
+ if (opts.model && opts.harness === "codex") codexConfig = { ...codexConfig, model: opts.model };
16738
16949
  if (codexConfig) {
16739
16950
  const key3 = "mcp_servers.paigy.env_vars";
16740
16951
  const configPath = join9(process.env.CODEX_HOME || join9(homedir9(), ".codex"), "config.toml");
@@ -16760,6 +16971,8 @@ function startSession(opts) {
16760
16971
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter3),
16761
16972
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
16762
16973
  ...opts.instance ? { PAIGY_INSTANCE_ID: opts.instance } : {},
16974
+ ...opts.model && opts.harness === "claude" ? { CLAUDE_MODEL_CONFIG: JSON.stringify({ provider: "anthropic", model: opts.model }) } : {},
16975
+ ...opts.model && opts.harness === "agy" ? { AGY_MODEL_CONFIG: JSON.stringify({ model: opts.model }) } : {},
16763
16976
  ...codexConfig ? { CODEX_CONFIG: JSON.stringify(codexConfig) } : {},
16764
16977
  ...opts.session ?? opts.resume ? { PAIGY_SESSION_ID: opts.session ?? opts.resume } : {},
16765
16978
  // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
@@ -17728,7 +17941,7 @@ var claimedRun = (sessionId2) => {
17728
17941
  const slot = sessionSlot(sessionId2);
17729
17942
  return { slot, key: runKey(slot) };
17730
17943
  };
17731
- var launchRun = (spec) => ({ session: spec.sessionId });
17944
+ var launchRun = (spec) => ({ session: spec.sessionId, model: spec.model });
17732
17945
  var BOUND_ELSEWHERE_MS = 10 * 6e4;
17733
17946
  var boundElsewhere = /* @__PURE__ */ new Map();
17734
17947
  var BOUND_SKIP = "is bound to the session it was paired in, which this host cannot name \u2014 run `paigy-harness handoff` as it, in its folder, to record it";
@@ -17837,7 +18050,7 @@ function startHost(opts) {
17837
18050
  });
17838
18051
  void heartbeat({
17839
18052
  version: VERSION,
17840
- harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
18053
+ harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status, models: a.models })),
17841
18054
  workspaces: listWorkspaces(opts.wsDeps),
17842
18055
  // The repos inside them, so a session started for work on one opens IN it (Goal 26982211).
17843
18056
  repos: listRepos(opts.wsDeps)
@@ -17929,9 +18142,14 @@ function startHost(opts) {
17929
18142
  let lastPresence = /* @__PURE__ */ new Map();
17930
18143
  async function evidence() {
17931
18144
  const presence = await presenceMap();
17932
- lastHolders = localHolders();
18145
+ lastHolders = localHolders({ unreadable: (file) => {
18146
+ if (unreadable.has(file)) return;
18147
+ unreadable.add(file);
18148
+ opts.log(`\u25CC session record ${file} has no object in it \u2014 the process it names reads as not running here`);
18149
+ } });
17933
18150
  return { presence, holders: lastHolders };
17934
18151
  }
18152
+ const unreadable = /* @__PURE__ */ new Set();
17935
18153
  let lastHolders = /* @__PURE__ */ new Map();
17936
18154
  async function wakeSlot(slot, { presence, holders }, cause = "sweep") {
17937
18155
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/harness",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "description": "Run Claude Code / Codex on this machine, bridged to Paigy — launchable from your phone.",
5
5
  "license": "MIT",
6
6
  "type": "module",