@rallycry/conveyor-agent 11.0.22 → 11.0.24

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.
@@ -29,7 +29,7 @@ import {
29
29
  spawnOptionsFingerprint,
30
30
  transcriptSize,
31
31
  turnOptionsFrom
32
- } from "./chunk-W5INK3NE.js";
32
+ } from "./chunk-WCJ6HRVU.js";
33
33
  import {
34
34
  AGENT_STATUS_REASON_USER_QUESTION,
35
35
  AgentConnection,
@@ -91,7 +91,7 @@ import {
91
91
  statWorkspacePath,
92
92
  updateRemoteToken,
93
93
  verifyGitCredential
94
- } from "./chunk-OMD77OIJ.js";
94
+ } from "./chunk-YNI2HFZE.js";
95
95
  import {
96
96
  registerBootMilestoneSocketFallback,
97
97
  reportBootMilestone
@@ -831,9 +831,11 @@ function mapUsage(message) {
831
831
  if (!isRecord(usage)) return void 0;
832
832
  const result = {};
833
833
  const input = numberField(usage, "input_tokens");
834
+ const output = numberField(usage, "output_tokens");
834
835
  const cacheRead = numberField(usage, "cache_read_input_tokens");
835
836
  const cacheCreation = numberField(usage, "cache_creation_input_tokens");
836
837
  if (input !== void 0) result.input_tokens = input;
838
+ if (output !== void 0) result.output_tokens = output;
837
839
  if (cacheRead !== void 0) result.cache_read_input_tokens = cacheRead;
838
840
  if (cacheCreation !== void 0) result.cache_creation_input_tokens = cacheCreation;
839
841
  return result;
@@ -1807,8 +1809,13 @@ async function writeHookSettings(dir, opts = {}) {
1807
1809
  // `--dangerously-skip-permissions`.
1808
1810
  // NOTE: a bare "*" rule is INVALID (the CLI rejects it and parks the TUI on
1809
1811
  // a Settings Warning dialog at boot) — hence the explicit ALLOW_RULES list.
1812
+ // `deny` carries the tools no pod agent may use (Artifact publishes to an
1813
+ // off-platform claude.ai URL; deliverables belong on the card). Bare tool
1814
+ // names are the valid rule form. The key is omitted when empty — see
1815
+ // HookSettingsOptions.disallowedTools for why that matters.
1810
1816
  permissions: {
1811
- allow: ALLOW_RULES
1817
+ allow: ALLOW_RULES,
1818
+ ...opts.disallowedTools && opts.disallowedTools.length > 0 ? { deny: opts.disallowedTools } : {}
1812
1819
  },
1813
1820
  hooks: {
1814
1821
  PreToolUse: buildPreToolUseHooks(helperPath, opts.gateAllTools === true),
@@ -2606,6 +2613,7 @@ var ClaudeTuiAdapter = class {
2606
2613
  permissionMode: options.permissionMode,
2607
2614
  settingsPath: input.settingsPath ?? "",
2608
2615
  ...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
2616
+ ...options.disallowedTools?.length ? { disallowedTools: options.disallowedTools } : {},
2609
2617
  ...input.mcpConfigPath ? { mcpConfigPath: input.mcpConfigPath, strictMcpConfig: true } : {}
2610
2618
  });
2611
2619
  return {
@@ -3155,7 +3163,8 @@ var PtySession = class {
3155
3163
  );
3156
3164
  await this.socket.listen();
3157
3165
  const { settingsPath } = await writeHookSettings(this.tempDir, {
3158
- gateAllTools: this.options.permissionMode === "plan"
3166
+ gateAllTools: this.options.permissionMode === "plan",
3167
+ ...this.options.disallowedTools?.length ? { disallowedTools: this.options.disallowedTools } : {}
3159
3168
  });
3160
3169
  await this.setupToolServers();
3161
3170
  const transcriptPath = sessionTranscriptPath(this.options.cwd, sessionId);
@@ -5641,7 +5650,8 @@ function buildDiscoveryPrompt(context, runnerMode) {
5641
5650
  ] : [],
5642
5651
  ``,
5643
5652
  `### Contracts this environment enforces`,
5644
- `- You are READ-ONLY. File writes are denied outside \`.claude/plans/\`, and so are the commands that would start the work: git commit/push/merge/rebase/checkout, git apply, gh pr create, package publish. Everything else runs without an approval prompt \u2014 the pod is a sandbox, so investigate freely (build, test, query the dev DB, read logs).`,
5653
+ `- You are READ-ONLY. File writes are denied outside \`.claude/plans/\` and \`/tmp/\`, and so are the commands that would start the work: git commit/push/merge/rebase/checkout, git apply, gh pr create, package publish. Everything else runs without an approval prompt \u2014 the pod is a sandbox, so investigate freely (build, test, query the dev DB, read logs).`,
5654
+ `- Research deliverables the team should keep (a diagram, an HTML report, a screenshot, a data export) go on the card with \`mcp__conveyor__upload_attachment\`: write the file under \`/tmp/\` first, and pass \`title\` as the caption so it shows in the thread. Never publish one as a Claude artifact.`,
5645
5655
  `- A plan file on disk is NOT the plan. Only \`update_task\` saves it to the card.`,
5646
5656
  `- **ExitPlanMode is the LAST step and it validates.** It fails until the card has a plan, story points, risk, and a title \u2014 the plan comes from \`update_task\`, and story points, risk and title from \`update_task_properties\`. Calling it early wastes a turn on a denial.`,
5647
5657
  `- ExitPlanMode does NOT start building. It parks this session for human review; the team decides when to build.`,
@@ -6340,7 +6350,7 @@ Working rules:`,
6340
6350
  `- When a build/lint/test run fails, capture its output to a file once and grep the file \u2014 never re-run the suite just to re-filter the same output.`,
6341
6351
  `- App servers (including development and production servers) keep running after startup. Start them with the harness background/session facility, then run a separate bounded HTTP readiness check and continue verification. Never wait for server exit or a completion notification to mean the app is ready. If readiness times out, inspect build output and process/resource state before restarting.`,
6342
6352
  runtimeTui === "codex" ? `- Waiting on long-running commands: retain and resume the command session until it returns an exit result. Finish each required gate before ending the task. Never pretend a completion notification will resume you, and never start another gate while the current one is running.` : `- Waiting on long-running commands: if a gate finishes in under ~2 minutes, run it in the foreground with a timeout. For a longer one, launch it with run_in_background and STOP; a completion notification arrives when it finishes, and tracked background work holds the workspace awake for up to 45 minutes. Inspect any gate approaching that cap. For the final pre-PR gate a bounded foreground run (\`timeout 590 <gate>\` with Bash \`timeout: 600000\`) is still preferred as defense in depth. A pod restart loses both foreground and background processes. Never busy-wait with sleep/pgrep/tail loops, and never re-run the suite to escape a wait that looks stalled.`,
6343
- runtimeTui === "codex" ? `- Do not end your turn to wait on a required gate. Resume its command session, inspect its exit result, and then continue the checklist.` : `- For a finite background gate, end your turn and its completion notification re-invokes you within the bounded liveness window. Never emit filler commands (\`echo waiting\`, \`true\`, \`sleep N; echo done\`) to "stay alive" \u2014 they are detected and blocked. The proven long-wait shape: start the job with run_in_background, then end the turn.${agentMode === "review" ? "" : " For CI on a pushed commit the shape is `wait_for_checks`, then end the turn (the pod may sleep; the GitHub result wakes it)."} Arm a ScheduleWakeup (delaySeconds 900-1500, prompt restating your next steps) only when nothing will notify you \u2014 a deploy, a remote queue \u2014 never for CI, and never as insurance against a background job's own notification, which does fire.`,
6353
+ runtimeTui === "codex" ? `- Do not end your turn to wait on a required gate. Resume its command session, inspect its exit result, and then continue the checklist.` : `- For a finite background gate, end your turn and its completion notification re-invokes you within the bounded liveness window. Never emit filler commands (\`echo waiting\`, \`true\`, \`sleep N; echo done\`) to "stay alive" \u2014 they are detected and blocked. The proven long-wait shape: start the job with run_in_background, then end the turn.${agentMode === "review" ? "" : " For CI on a pushed commit the shape is `wait_for_checks`, then end the turn (the pod may sleep; the GitHub result wakes it)."} Arm a ScheduleWakeup (delaySeconds 900-1500, prompt restating your next steps) only when nothing will notify you \u2014 a deploy, a remote queue \u2014 never for CI, and never as insurance against a background job's own notification, which does fire. One armed wakeup keeps this workspace awake until it fires (at most one hour), so arm exactly one and end the turn \u2014 a second one "as insurance" buys nothing.`,
6344
6354
  `
6345
6355
  Git:`,
6346
6356
  `- Stay on \`${context.githubBranch}\` for the whole task: do not check out another branch and do not create one. It was cut from \`${context.baseBranch}\`, and PRs target that automatically.`,
@@ -6860,7 +6870,7 @@ var SEARCH_STATUSES = [
6860
6870
  "Complete",
6861
6871
  "Cancelled"
6862
6872
  ];
6863
- var SEARCH_CARD_TYPES = ["task", "incident", "suggestion"];
6873
+ var SEARCH_CARD_TYPES = ["task", "incident", "suggestion", "decision"];
6864
6874
  var searchFilterFields = {
6865
6875
  tagNames: f.optional(
6866
6876
  f.array(f.string(), {
@@ -7502,6 +7512,11 @@ var uploadAttachmentContract = defineToolContract({
7502
7512
  title: f.optional(
7503
7513
  f.string({ desc: "Short caption posted with the file (defaults to the file name)" })
7504
7514
  ),
7515
+ comment: f.optional(
7516
+ f.string({
7517
+ desc: "Alias of `title` (the external conveyor-mcp surface names this field `comment`). Provide at most one of the two."
7518
+ })
7519
+ ),
7505
7520
  tags: attachmentTags
7506
7521
  }
7507
7522
  },
@@ -7514,6 +7529,11 @@ var uploadAttachmentContract = defineToolContract({
7514
7529
  comment: f.optional(
7515
7530
  f.string({ desc: "When set, also posts the attachment to the task chat with this text" })
7516
7531
  ),
7532
+ title: f.optional(
7533
+ f.string({
7534
+ desc: "Alias of `comment` (the in-pod agent surface names this field `title`). Provide at most one of the two."
7535
+ })
7536
+ ),
7517
7537
  tags: attachmentTags,
7518
7538
  mimeType: f.optional(
7519
7539
  f.string({ desc: "Override the mime type inferred from the file extension" })
@@ -7553,6 +7573,293 @@ var createSuggestionContract = defineToolContract({
7553
7573
  }
7554
7574
  });
7555
7575
  var suggestionsContracts = [createSuggestionContract];
7576
+ var OPTION_KINDS = ["current", "planned", "proposed"];
7577
+ var STATUS_LABELS = ["Open", "Decided", "Cancelled"];
7578
+ var optionDescription = "What this option means in the product, written as GIVEN / WHEN / THEN. Concrete enough that a reader six months from now can tell which behaviour was chosen.";
7579
+ var optionsDesc = "Two or three options. More than three means the question is not framed yet \u2014 narrow it or split it. Mark the option describing what the code does today as kind: current.";
7580
+ var defaultOptionDesc = "1-based position of the option that wins if nobody decides by the deadline. Required: silence has to become a decision, never a stall.";
7581
+ var agentOption = f.object(
7582
+ {
7583
+ label: f.string({ min: 1, desc: "Short name for the option, under 80 characters" }),
7584
+ description: f.string({ min: 1, desc: optionDescription }),
7585
+ kind: f.optional(
7586
+ f.enum(OPTION_KINDS, {
7587
+ desc: 'current = what the code does today, planned = already agreed but unbuilt, proposed = new. Defaults to "proposed".'
7588
+ })
7589
+ )
7590
+ },
7591
+ { desc: "One option people can choose" }
7592
+ );
7593
+ var createDecisionContract = defineToolContract({
7594
+ name: "create_decision",
7595
+ agent: {
7596
+ description: `Raise a product decision: a fork the plan cannot be right without. Two or three options, one default, a deadline. Run list_decisions FIRST \u2014 an answered decision is the answer, and re-asking it wastes everybody's time. Do NOT file a decision for an assumption you can simply state: an assumed behaviour belongs in the plan as a GIVEN / WHEN / THEN line under "Assumed behaviours". Returns the decision in full (the same JSON as get_decision, with option ids and the deadline) plus which cards were linked.`,
7597
+ fields: {
7598
+ question: f.string({
7599
+ min: 1,
7600
+ desc: "The question, as one plain sentence ending in a question mark"
7601
+ }),
7602
+ summary: f.optional(
7603
+ f.string({
7604
+ desc: cardDescriptionDesc("One or two sentences of context for the card face")
7605
+ })
7606
+ ),
7607
+ context: f.string({
7608
+ min: 1,
7609
+ desc: "Markdown background: why this is being asked now, and what breaks if it is answered wrongly"
7610
+ }),
7611
+ options: f.array(agentOption, { min: 2, desc: optionsDesc }),
7612
+ default_option: f.number({ int: true, min: 1, max: 3, desc: defaultOptionDesc }),
7613
+ deadline_hours: f.optional(
7614
+ f.number({ min: 1, max: 720, desc: "Hours until the default applies. Default 72." })
7615
+ ),
7616
+ impacted_cards: f.optional(
7617
+ f.array(f.string(), {
7618
+ desc: "Slugs or ids of cards this decision blocks. They cannot start until it is settled, and they are un-blocked the moment it is. Blocking a card needs Moderate access: without it the decision is still raised and each ref comes back under refused."
7619
+ })
7620
+ ),
7621
+ include_current_card: f.optional(
7622
+ f.boolean({
7623
+ desc: "Also block the card this session is working on. Defaults to true \u2014 you are raising this because your own plan is stuck on it. Pass false to raise the question without stopping your own work."
7624
+ })
7625
+ ),
7626
+ tag_names: f.optional(
7627
+ f.array(f.string(), {
7628
+ desc: "Tag names. The settled decision is written into each tag's overview, which is how the next agent finds it."
7629
+ })
7630
+ ),
7631
+ raised_by_pr: f.optional(
7632
+ f.number({ int: true, min: 1, desc: "PR number that surfaced the question" })
7633
+ ),
7634
+ raised_by_card: f.optional(
7635
+ f.string({
7636
+ desc: "Slug or id of the card that raised the question. A link back, not a block \u2014 use impacted_cards to block a card."
7637
+ })
7638
+ ),
7639
+ deadline_at: f.optional(
7640
+ f.string({
7641
+ desc: "Absolute deadline as an ISO 8601 timestamp, e.g. 2026-09-25T17:00:00Z. Wins over deadline_hours when both are given; must be in the future and within 720 hours."
7642
+ })
7643
+ )
7644
+ }
7645
+ },
7646
+ mcp: {
7647
+ description: "Raise a product decision on a Conveyor project: a fork that has to be settled before the work can be planned correctly. Two or three options, one default that applies if nobody decides, and a deadline. Search list_decisions first \u2014 a Decided decision is the answer. Pass projectId to target a specific project; otherwise the configured default project is used.",
7648
+ fields: {
7649
+ projectId: mcpProjectId,
7650
+ question: f.string({ desc: "The question, as one plain sentence" }),
7651
+ summary: f.optional(f.string({ desc: cardDescriptionDesc("Context for the card face") })),
7652
+ context: f.string({ desc: "Markdown background: why this is being asked now" }),
7653
+ options: f.array(agentOption, { min: 2, desc: optionsDesc }),
7654
+ defaultOption: f.number({ int: true, min: 1, max: 3, desc: defaultOptionDesc }),
7655
+ deadlineHours: f.optional(
7656
+ f.number({ min: 1, max: 720, desc: "Hours until the default applies. Default 72." })
7657
+ ),
7658
+ impactedCards: f.optional(
7659
+ f.array(f.string(), {
7660
+ desc: "Slugs or ids of cards this decision blocks. Needs Moderate access; without it each ref comes back under refused and the decision is still raised."
7661
+ })
7662
+ ),
7663
+ tagNames: f.optional(f.array(f.string(), { desc: "Tag names to categorize the decision" })),
7664
+ raisedByPr: f.optional(
7665
+ f.number({ int: true, min: 1, desc: "PR number that surfaced the question" })
7666
+ ),
7667
+ raisedByCard: f.optional(
7668
+ f.string({
7669
+ desc: "Slug or id of the card that raised the question. A link back, not a block \u2014 use impactedCards to block a card."
7670
+ })
7671
+ ),
7672
+ deadlineAt: f.optional(
7673
+ f.string({
7674
+ desc: "Absolute deadline (ISO 8601). Wins over deadlineHours when both are given; must be in the future and within 720 hours."
7675
+ })
7676
+ )
7677
+ }
7678
+ }
7679
+ });
7680
+ var listDecisionsContract = defineToolContract({
7681
+ name: "list_decisions",
7682
+ agent: {
7683
+ description: `Search this project's decisions. Run this BEFORE planning anything in an area: "have we already decided how X behaves?" Each row carries the summary, who raised it (and from which card or PR), when, and \u2014 once settled \u2014 the resolution, so the list doubles as the decision log. Filter by card to see what is blocking a specific card.`,
7684
+ fields: {
7685
+ status: f.optional(
7686
+ f.enum(STATUS_LABELS, {
7687
+ desc: "Open = still being voted on, Decided = settled, Cancelled = dropped"
7688
+ })
7689
+ ),
7690
+ tag: f.optional(f.string({ desc: 'Tag name, e.g. "slack"' })),
7691
+ card: f.optional(
7692
+ f.string({ desc: "Slug or id of a card \u2014 returns the decisions it is blocked by" })
7693
+ ),
7694
+ query: f.optional(f.string({ desc: "Text search on the question" })),
7695
+ limit: f.optional(f.number({ desc: "Max rows to return (default 50, max 200)" }))
7696
+ }
7697
+ },
7698
+ mcp: {
7699
+ description: `List and search a Conveyor project's decisions. Decided rows include their resolution, so this is the fastest way to answer "did we already settle this?". Pass projectId to target a specific project; otherwise the configured default project is used.`,
7700
+ fields: {
7701
+ projectId: mcpProjectId,
7702
+ status: f.optional(f.enum(STATUS_LABELS, { desc: "Filter by state" })),
7703
+ tag: f.optional(f.string({ desc: "Tag name to filter by" })),
7704
+ card: f.optional(f.string({ desc: "Card slug or id \u2014 decisions blocking that card" })),
7705
+ query: f.optional(f.string({ desc: "Text search on the question" })),
7706
+ limit: f.optional(
7707
+ f.number({ int: true, min: 1, max: 200, desc: "Max rows to return (default 50)" })
7708
+ )
7709
+ }
7710
+ }
7711
+ });
7712
+ var getDecisionContract = defineToolContract({
7713
+ name: "get_decision",
7714
+ agent: {
7715
+ description: "Read one decision in full: its summary and context, who raised it and from where, every option with its GIVEN / WHEN / THEN, every vote with who cast it and why, the running tally, the cards it blocks, and the resolution once there is one. Use read_task_chat with the same id for the discussion thread.",
7716
+ fields: {
7717
+ decision: f.string({ min: 1, desc: "Decision slug or id" })
7718
+ }
7719
+ },
7720
+ mcp: {
7721
+ description: "Read one Conveyor decision in full \u2014 context, options, votes with reasons, tally, impacted cards, and the resolution. Pass projectId to disambiguate a slug; otherwise the configured default project is used.",
7722
+ fields: {
7723
+ projectId: mcpProjectId,
7724
+ decision: f.string({ desc: "Decision slug or id" })
7725
+ }
7726
+ }
7727
+ });
7728
+ var voteDecisionContract = defineToolContract({
7729
+ name: "vote_decision",
7730
+ agent: {
7731
+ description: "Vote on an open decision. One vote per voter: voting again replaces your previous one. Always give a reason \u2014 the resolution weighs reasons over counts, and a vote with no reason carries almost nothing.",
7732
+ fields: {
7733
+ decision: f.string({ min: 1, desc: "Decision slug or id" }),
7734
+ option: f.string({ min: 1, desc: "Option id, or its 1-based position" }),
7735
+ reason: f.optional(
7736
+ f.string({ desc: "Why this option, in one or two sentences (max 255 characters)" })
7737
+ )
7738
+ }
7739
+ },
7740
+ mcp: {
7741
+ description: "Vote on an open Conveyor decision, with a reason. One vote per voter \u2014 voting again replaces the previous one.",
7742
+ fields: {
7743
+ projectId: mcpProjectId,
7744
+ decision: f.string({ desc: "Decision slug or id" }),
7745
+ option: f.string({ desc: "Option id, or its 1-based position" }),
7746
+ reason: f.optional(f.string({ desc: "Why this option (max 255 characters)" }))
7747
+ }
7748
+ }
7749
+ });
7750
+ var resolveDecisionContract = defineToolContract({
7751
+ name: "resolve_decision",
7752
+ agent: {
7753
+ description: "Settle a decision. This is not a formality: the resolution is written into the plan of every card the decision blocked, into each of their chats, and into the overview of every tag it carries, and those cards become startable. Say what was decided in one or two sentences \u2014 a reader months later should not need to open the thread.",
7754
+ fields: {
7755
+ decision: f.string({ min: 1, desc: "Decision slug or id" }),
7756
+ option: f.string({ min: 1, desc: "Winning option id, or its 1-based position" }),
7757
+ resolution: f.string({
7758
+ min: 1,
7759
+ desc: "What was decided, in one or two plain sentences (max 255 characters)"
7760
+ }),
7761
+ notes: f.optional(
7762
+ f.string({ desc: "Markdown reasoning. Link any verdict attachment here." })
7763
+ )
7764
+ }
7765
+ },
7766
+ mcp: {
7767
+ description: "Settle a Conveyor decision. The resolution is written into every impacted card's plan and chat and into its tags' overviews, and the impacted cards become startable.",
7768
+ fields: {
7769
+ projectId: mcpProjectId,
7770
+ decision: f.string({ desc: "Decision slug or id" }),
7771
+ option: f.string({ desc: "Winning option id, or its 1-based position" }),
7772
+ resolution: f.string({ desc: "What was decided (max 255 characters)" }),
7773
+ notes: f.optional(f.string({ desc: "Markdown reasoning" }))
7774
+ }
7775
+ }
7776
+ });
7777
+ var updateDecisionContract = defineToolContract({
7778
+ name: "update_decision",
7779
+ agent: {
7780
+ description: "Change an open decision: fix the question, summary, context or tags, rewrite the options while nobody has voted, move the deadline, link or unlink the cards it blocks, or cancel it. Cancelling un-blocks the impacted cards without answering the question, so only do it when the question stopped mattering.",
7781
+ fields: {
7782
+ decision: f.string({ min: 1, desc: "Decision slug or id" }),
7783
+ question: f.optional(f.string({ min: 1, desc: "New question, one plain sentence" })),
7784
+ summary: f.optional(
7785
+ f.string({ desc: cardDescriptionDesc("New one- or two-sentence summary") })
7786
+ ),
7787
+ context: f.optional(f.string({ min: 1, desc: "New markdown background" })),
7788
+ options: f.optional(
7789
+ f.array(agentOption, {
7790
+ min: 2,
7791
+ desc: "Replacement options. Accepted only while the decision has no votes \u2014 once somebody has voted, cancel it and raise a new one instead. Send default_option with it: the old default no longer exists."
7792
+ })
7793
+ ),
7794
+ default_option: f.optional(
7795
+ f.number({ int: true, min: 1, max: 3, desc: "1-based position of the new default" })
7796
+ ),
7797
+ tag_names: f.optional(
7798
+ f.array(f.string(), { desc: "Replacement tag names (replaces the current set)" })
7799
+ ),
7800
+ deadline_hours: f.optional(f.number({ desc: "New deadline, in hours from now. 1 to 720." })),
7801
+ deadline_at: f.optional(
7802
+ f.string({
7803
+ desc: "New absolute deadline (ISO 8601). Wins over deadline_hours; must be in the future and within 720 hours."
7804
+ })
7805
+ ),
7806
+ add_impacted_cards: f.optional(
7807
+ f.array(f.string(), { desc: "Slugs or ids of cards to block on this decision" })
7808
+ ),
7809
+ remove_impacted_cards: f.optional(
7810
+ f.array(f.string(), { desc: "Ids of cards to stop blocking" })
7811
+ ),
7812
+ status: f.optional(
7813
+ f.enum(["Cancelled", "Open"], {
7814
+ desc: "Cancelled drops the question; Open reopens a settled one and clears its answer"
7815
+ })
7816
+ )
7817
+ }
7818
+ },
7819
+ mcp: {
7820
+ description: "Change an open Conveyor decision: fix the question, summary, context or tags, rewrite the options while nobody has voted, move the deadline, link or unlink impacted cards, cancel it, or reopen a settled one.",
7821
+ fields: {
7822
+ projectId: mcpProjectId,
7823
+ decision: f.string({ desc: "Decision slug or id" }),
7824
+ question: f.optional(f.string({ desc: "New question" })),
7825
+ summary: f.optional(f.string({ desc: cardDescriptionDesc("New summary") })),
7826
+ context: f.optional(f.string({ desc: "New markdown background" })),
7827
+ options: f.optional(
7828
+ f.array(agentOption, {
7829
+ min: 2,
7830
+ desc: "Replacement options. Accepted only while the decision has no votes. Send defaultOption with it: the old default no longer exists."
7831
+ })
7832
+ ),
7833
+ defaultOption: f.optional(
7834
+ f.number({ int: true, min: 1, max: 3, desc: "1-based position of the new default" })
7835
+ ),
7836
+ tagNames: f.optional(f.array(f.string(), { desc: "Replacement tag names" })),
7837
+ deadlineHours: f.optional(
7838
+ f.number({ min: 1, max: 720, desc: "New deadline, in hours from now" })
7839
+ ),
7840
+ deadlineAt: f.optional(
7841
+ f.string({ desc: "New absolute deadline (ISO 8601). Wins over deadlineHours." })
7842
+ ),
7843
+ addImpactedCards: f.optional(
7844
+ f.array(f.string(), { desc: "Slugs or ids of cards to block on this decision" })
7845
+ ),
7846
+ removeImpactedCards: f.optional(
7847
+ f.array(f.string(), { desc: "Ids of cards to stop blocking" })
7848
+ ),
7849
+ status: f.optional(
7850
+ f.enum(["Cancelled", "Open"], { desc: "Cancel it, or reopen a settled one" })
7851
+ )
7852
+ }
7853
+ }
7854
+ });
7855
+ var decisionsContracts = [
7856
+ createDecisionContract,
7857
+ listDecisionsContract,
7858
+ getDecisionContract,
7859
+ voteDecisionContract,
7860
+ resolveDecisionContract,
7861
+ updateDecisionContract
7862
+ ];
7556
7863
  var createPullRequestContract = defineToolContract({
7557
7864
  name: "create_pull_request",
7558
7865
  agent: {
@@ -8213,6 +8520,7 @@ var TOOL_CONTRACTS = Object.fromEntries(
8213
8520
  ...subtasksContracts,
8214
8521
  ...attachmentsContracts,
8215
8522
  ...suggestionsContracts,
8523
+ ...decisionsContracts,
8216
8524
  ...pullRequestContracts,
8217
8525
  ...integrationsContracts,
8218
8526
  ...driveContracts,
@@ -8276,10 +8584,11 @@ function buildReadTaskChatTool(connection) {
8276
8584
  taskId: task_id
8277
8585
  });
8278
8586
  return textResult(JSON.stringify(messages, null, 2));
8279
- } catch {
8587
+ } catch (error) {
8280
8588
  return textResult(
8281
8589
  JSON.stringify({
8282
- note: "Could not fetch live chat. Chat history was provided in the initial context."
8590
+ error: `Failed to read chat: ${error instanceof Error ? error.message : "Unknown error"}`,
8591
+ note: "Usually transient \u2014 retry. Chat history up to this session's start was provided in the initial context."
8283
8592
  })
8284
8593
  );
8285
8594
  }
@@ -9063,7 +9372,8 @@ Paste this into the PR description to show it inline:
9063
9372
  ${snippet}`;
9064
9373
  }
9065
9374
  function buildUploadAttachmentTool(connection, config) {
9066
- return defineContractTool(uploadAttachmentContract, async ({ path, title, tags }) => {
9375
+ return defineContractTool(uploadAttachmentContract, async ({ path, title, comment, tags }) => {
9376
+ const caption = title ?? comment;
9067
9377
  try {
9068
9378
  const filePath = isAbsolute(path) ? path : join16(config.workspaceDir, path);
9069
9379
  const mimeType = inferMimeType(filePath);
@@ -9099,11 +9409,11 @@ function buildUploadAttachmentTool(connection, config) {
9099
9409
  const result = await connection.call("confirmFileUpload", {
9100
9410
  sessionId: connection.sessionId,
9101
9411
  fileId,
9102
- title,
9412
+ title: caption,
9103
9413
  tags
9104
9414
  });
9105
9415
  return textResult(
9106
- `Uploaded ${fileName} (${info.size} bytes) and posted it to the task chat${title ? ` with caption "${title}"` : ""}. File ID: ${result.fileId}${describeTags(result.appliedTags, result.unknownTags)}${describeEmbed(fileName, result.downloadUrl)}`
9416
+ `Uploaded ${fileName} (${info.size} bytes) and posted it to the task chat${caption ? ` with caption "${caption}"` : ""}. File ID: ${result.fileId}${describeTags(result.appliedTags, result.unknownTags)}${describeEmbed(fileName, result.downloadUrl)}`
9107
9417
  );
9108
9418
  } catch (error) {
9109
9419
  return textResult(
@@ -10465,6 +10775,155 @@ ${res.map(describeItem).join("\n")}`);
10465
10775
  ];
10466
10776
  }
10467
10777
 
10778
+ // src/tools/decision-tools.ts
10779
+ function errText5(prefix, error) {
10780
+ return textResult(`${prefix}: ${error instanceof Error ? error.message : "Unknown error"}`);
10781
+ }
10782
+ function buildDecisionTools(connection, projectId) {
10783
+ return [
10784
+ ...readTools2(connection, projectId),
10785
+ ...raiseAndVoteTools(connection, projectId),
10786
+ ...settleTools(connection, projectId)
10787
+ ];
10788
+ }
10789
+ function readTools2(connection, projectId) {
10790
+ return [
10791
+ defineContractTool(
10792
+ listDecisionsContract,
10793
+ async (input) => {
10794
+ try {
10795
+ const res = await connection.call("listProjectDecisions", {
10796
+ projectId,
10797
+ status: input.status,
10798
+ tag: input.tag,
10799
+ card: input.card,
10800
+ query: input.query,
10801
+ limit: input.limit
10802
+ });
10803
+ if (res.decisions.length === 0) {
10804
+ return textResult(
10805
+ "No decisions match. Nothing has been settled here, so you are free to choose \u2014 state the choice in the plan as a GIVEN / WHEN / THEN assumption, or raise a decision with create_decision if the plan cannot be right without an answer."
10806
+ );
10807
+ }
10808
+ return textResult(JSON.stringify(res.decisions, null, 2));
10809
+ } catch (error) {
10810
+ return errText5("Failed to list decisions", error);
10811
+ }
10812
+ },
10813
+ { annotations: { readOnlyHint: true } }
10814
+ ),
10815
+ defineContractTool(
10816
+ getDecisionContract,
10817
+ async (input) => {
10818
+ try {
10819
+ const res = await connection.call("getProjectDecision", {
10820
+ projectId,
10821
+ decision: input.decision
10822
+ });
10823
+ return textResult(JSON.stringify(res, null, 2));
10824
+ } catch (error) {
10825
+ return errText5("Failed to read the decision", error);
10826
+ }
10827
+ },
10828
+ { annotations: { readOnlyHint: true } }
10829
+ )
10830
+ ];
10831
+ }
10832
+ function raiseAndVoteTools(connection, projectId) {
10833
+ return [
10834
+ defineContractTool(createDecisionContract, async (input) => {
10835
+ try {
10836
+ const res = await connection.call("createProjectDecision", {
10837
+ projectId,
10838
+ question: input.question,
10839
+ summary: input.summary,
10840
+ context: input.context,
10841
+ options: input.options,
10842
+ defaultOption: input.default_option,
10843
+ deadlineHours: input.deadline_hours,
10844
+ impactedCards: input.impacted_cards,
10845
+ includeCurrentCard: input.include_current_card,
10846
+ sessionId: connection.sessionId,
10847
+ tagNames: input.tag_names,
10848
+ raisedByPr: input.raised_by_pr,
10849
+ raisedByCard: input.raised_by_card,
10850
+ deadlineAt: input.deadline_at
10851
+ });
10852
+ const { impactedLinked, refused, ...detail } = res;
10853
+ const refusedLine = refused.length > 0 ? `
10854
+
10855
+ Not linked: ${refused.map((r) => `${r.ref} (${r.reason})`).join("; ")}` : "";
10856
+ return textResult(
10857
+ `Raised decision ${res.slug} \u2014 ${res.url}
10858
+ Blocking ${impactedLinked.length} card(s).${refusedLine}
10859
+
10860
+ ${JSON.stringify(detail, null, 2)}`
10861
+ );
10862
+ } catch (error) {
10863
+ return errText5("Failed to raise the decision", error);
10864
+ }
10865
+ }),
10866
+ defineContractTool(voteDecisionContract, async (input) => {
10867
+ try {
10868
+ const res = await connection.call("voteProjectDecision", {
10869
+ projectId,
10870
+ decision: input.decision,
10871
+ option: input.option,
10872
+ reason: input.reason
10873
+ });
10874
+ const tally = res.options.map((o) => `${o.label}: ${o.votes}`).join(", ");
10875
+ return textResult(`Voted on ${res.slug}. Tally \u2014 ${tally}.`);
10876
+ } catch (error) {
10877
+ return errText5("Failed to vote", error);
10878
+ }
10879
+ })
10880
+ ];
10881
+ }
10882
+ function settleTools(connection, projectId) {
10883
+ return [
10884
+ defineContractTool(resolveDecisionContract, async (input) => {
10885
+ try {
10886
+ const res = await connection.call("resolveProjectDecision", {
10887
+ projectId,
10888
+ decision: input.decision,
10889
+ option: input.option,
10890
+ resolution: input.resolution,
10891
+ notes: input.notes
10892
+ });
10893
+ return textResult(
10894
+ `Settled ${res.slug} as "${res.chosenOption}". ${res.impactedCount} card(s) were written back to and are no longer blocked.`
10895
+ );
10896
+ } catch (error) {
10897
+ return errText5("Failed to settle the decision", error);
10898
+ }
10899
+ }),
10900
+ defineContractTool(updateDecisionContract, async (input) => {
10901
+ try {
10902
+ const res = await connection.call("updateProjectDecision", {
10903
+ projectId,
10904
+ decision: input.decision,
10905
+ question: input.question,
10906
+ summary: input.summary,
10907
+ context: input.context,
10908
+ options: input.options,
10909
+ defaultOption: input.default_option,
10910
+ tagNames: input.tag_names,
10911
+ deadlineHours: input.deadline_hours,
10912
+ deadlineAt: input.deadline_at,
10913
+ addImpactedCards: input.add_impacted_cards,
10914
+ removeImpactedCards: input.remove_impacted_cards,
10915
+ status: input.status
10916
+ });
10917
+ return textResult(
10918
+ `Updated ${res.slug} \u2014 ${res.status}, ${res.impactedCount} impacted card(s), deadline ${res.deadlineAt}.`
10919
+ );
10920
+ } catch (error) {
10921
+ return errText5("Failed to update the decision", error);
10922
+ }
10923
+ })
10924
+ ];
10925
+ }
10926
+
10468
10927
  // src/tools/connected-tools.ts
10469
10928
  function connectedToolsFor(connection, context) {
10470
10929
  const projectId = context?.projectId;
@@ -10474,6 +10933,9 @@ function connectedToolsFor(connection, context) {
10474
10933
  buildListProjectIntegrationsTool(connection, projectId),
10475
10934
  ...context.chatChannelsConfigured ? buildChannelTools(connection, projectId) : [],
10476
10935
  ...context.meetingsAvailable ? buildMeetingTools(connection, projectId) : [],
10936
+ // Always available: the decision tables exist on every project, and a fork
10937
+ // an agent cannot file is a fork it assumes its way past instead.
10938
+ ...buildDecisionTools(connection, projectId),
10477
10939
  ...context.googleAnalyticsConfigured ? [buildAnalyticsSummaryTool(connection, projectId)] : [],
10478
10940
  ...context.gcpLogsConfigured ? [buildQueryGcpLogsTool(connection, projectId)] : [],
10479
10941
  ...context.grafanaLogsConfigured ? [buildQueryGrafanaLogsTool(connection, projectId)] : []
@@ -10846,6 +11308,26 @@ function safeVoid(promise, context) {
10846
11308
  });
10847
11309
  }
10848
11310
  }
11311
+ function addTurnUsage(total, usage) {
11312
+ const next = {
11313
+ inputTokens: total.inputTokens + (usage.input_tokens ?? 0),
11314
+ outputTokens: total.outputTokens + (usage.output_tokens ?? 0),
11315
+ cacheReadInputTokens: total.cacheReadInputTokens + (usage.cache_read_input_tokens ?? 0),
11316
+ cacheCreationInputTokens: total.cacheCreationInputTokens + (usage.cache_creation_input_tokens ?? 0),
11317
+ totalTokens: 0
11318
+ };
11319
+ next.totalTokens = next.inputTokens + next.outputTokens + next.cacheReadInputTokens + next.cacheCreationInputTokens;
11320
+ return next;
11321
+ }
11322
+ function emptyTurnUsage() {
11323
+ return {
11324
+ inputTokens: 0,
11325
+ outputTokens: 0,
11326
+ cacheReadInputTokens: 0,
11327
+ cacheCreationInputTokens: 0,
11328
+ totalTokens: 0
11329
+ };
11330
+ }
10849
11331
  function epochSecondsToISO(value) {
10850
11332
  if (typeof value === "string") return value;
10851
11333
  if (typeof value !== "number" || value <= 0) return void 0;
@@ -10927,11 +11409,16 @@ function emitContextUpdate(modelUsage, host, context, lastAssistantUsage) {
10927
11409
  });
10928
11410
  }
10929
11411
  }
10930
- function handleSuccessResult(event, host, context, startTime, lastAssistantUsage) {
11412
+ function handleSuccessResult(event, host, context, startTime, lastAssistantUsage, turnUsage) {
10931
11413
  const durationMs = Date.now() - startTime;
10932
11414
  const summary = event.result || "Task completed.";
10933
11415
  const retriable = isRetriableMessage(summary);
10934
- host.connection.sendEvent({ type: "completed", summary, durationMs });
11416
+ host.connection.sendEvent({
11417
+ type: "completed",
11418
+ summary,
11419
+ durationMs,
11420
+ ...turnUsage && turnUsage.totalTokens > 0 ? { usage: turnUsage } : {}
11421
+ });
10935
11422
  const { modelUsage } = event;
10936
11423
  if (modelUsage && typeof modelUsage === "object") {
10937
11424
  emitContextUpdate(modelUsage, host, context, lastAssistantUsage);
@@ -10952,7 +11439,7 @@ function handleErrorResult(event, host) {
10952
11439
  host.connection.sendEvent({ type: "error", message: errorMsg });
10953
11440
  return { retriable };
10954
11441
  }
10955
- function handleResultEvent(event, host, context, startTime, lastAssistantUsage) {
11442
+ function handleResultEvent(event, host, context, startTime, lastAssistantUsage, turnUsage) {
10956
11443
  const resultSummary = event.subtype === "success" ? event.result : event.errors.join(", ");
10957
11444
  if (event.subtype === "success") {
10958
11445
  const result2 = handleSuccessResult(
@@ -10960,15 +11447,16 @@ function handleResultEvent(event, host, context, startTime, lastAssistantUsage)
10960
11447
  host,
10961
11448
  context,
10962
11449
  startTime,
10963
- lastAssistantUsage
11450
+ lastAssistantUsage,
11451
+ turnUsage
10964
11452
  );
10965
11453
  return { ...result2, resultSummary };
10966
11454
  }
10967
11455
  const result = handleErrorResult(event, host);
10968
11456
  return { ...result, resultSummary };
10969
11457
  }
10970
- async function emitResultEvent(event, host, context, startTime, lastAssistantUsage) {
10971
- const result = handleResultEvent(event, host, context, startTime, lastAssistantUsage);
11458
+ async function emitResultEvent(event, host, context, startTime, lastAssistantUsage, turnUsage) {
11459
+ const result = handleResultEvent(event, host, context, startTime, lastAssistantUsage, turnUsage);
10972
11460
  const durationMs = Date.now() - startTime;
10973
11461
  if (event.subtype === "success") {
10974
11462
  const successEvent = event;
@@ -10976,7 +11464,8 @@ async function emitResultEvent(event, host, context, startTime, lastAssistantUsa
10976
11464
  await host.callbacks.onEvent({
10977
11465
  type: "completed",
10978
11466
  summary,
10979
- durationMs
11467
+ durationMs,
11468
+ ...turnUsage && turnUsage.totalTokens > 0 ? { usage: turnUsage } : {}
10980
11469
  });
10981
11470
  } else if (!result.staleSession) {
10982
11471
  const errorEvent = event;
@@ -11082,13 +11571,20 @@ async function handleAssistantCase(event, host, turnToolCalls) {
11082
11571
  const msgUsage = event.message.usage;
11083
11572
  return msgUsage ?? void 0;
11084
11573
  }
11085
- async function handleResultCase(event, host, context, startTime, isTyping, lastAssistantUsage) {
11574
+ async function handleResultCase(event, host, context, startTime, isTyping, lastAssistantUsage, turnUsage) {
11086
11575
  let stoppedTyping = false;
11087
11576
  if (isTyping) {
11088
11577
  host.connection.sendTypingStop();
11089
11578
  stoppedTyping = true;
11090
11579
  }
11091
- const resultInfo = await emitResultEvent(event, host, context, startTime, lastAssistantUsage);
11580
+ const resultInfo = await emitResultEvent(
11581
+ event,
11582
+ host,
11583
+ context,
11584
+ startTime,
11585
+ lastAssistantUsage,
11586
+ turnUsage
11587
+ );
11092
11588
  return {
11093
11589
  retriable: resultInfo.retriable,
11094
11590
  resultSummary: resultInfo.resultSummary,
@@ -11192,7 +11688,10 @@ async function processAssistantCase(event, host, state) {
11192
11688
  state.isTyping = true;
11193
11689
  }
11194
11690
  const usage = await handleAssistantCase(event, host, state.turnToolCalls);
11195
- if (usage) state.lastAssistantUsage = usage;
11691
+ if (usage) {
11692
+ state.lastAssistantUsage = usage;
11693
+ state.turnUsage = addTurnUsage(state.turnUsage, usage);
11694
+ }
11196
11695
  if (!state.sawApiError || event.isApiError && !state.terminalApiError) {
11197
11696
  const fullText = event.message.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
11198
11697
  if (API_ERROR_PATTERN.test(fullText)) {
@@ -11211,7 +11710,8 @@ async function processResultCase(event, host, context, startTime, state) {
11211
11710
  context,
11212
11711
  startTime,
11213
11712
  state.isTyping,
11214
- state.lastAssistantUsage
11713
+ state.lastAssistantUsage,
11714
+ state.turnUsage
11215
11715
  );
11216
11716
  if (info.stoppedTyping) state.isTyping = false;
11217
11717
  state.retriable = info.retriable;
@@ -11244,6 +11744,7 @@ async function processEvents(events, context, host) {
11244
11744
  staleSession: void 0,
11245
11745
  authError: void 0,
11246
11746
  lastAssistantUsage: void 0,
11747
+ turnUsage: emptyTurnUsage(),
11247
11748
  turnToolCalls: [],
11248
11749
  questionPending: false
11249
11750
  };
@@ -11323,6 +11824,9 @@ function fallbackResetIso(rateLimitType, now = Date.now()) {
11323
11824
  return new Date(now + (weekly ? TWENTY_FOUR_HOURS_MS : FIVE_HOURS_MS)).toISOString();
11324
11825
  }
11325
11826
 
11827
+ // src/execution/tool-access.ts
11828
+ import { isAbsolute as isAbsolute3, posix } from "path";
11829
+
11326
11830
  // src/execution/task-property-utils.ts
11327
11831
  function collectMissingProps(taskProps) {
11328
11832
  const missing = [];
@@ -11465,6 +11969,12 @@ function isPlanFile(input) {
11465
11969
  const filePath = String(input.file_path ?? input.path ?? "");
11466
11970
  return filePath.includes(".claude/plans/");
11467
11971
  }
11972
+ function isScratchPath(input) {
11973
+ const filePath = String(input.file_path ?? input.path ?? "");
11974
+ if (!isAbsolute3(filePath)) return false;
11975
+ return posix.normalize(filePath).startsWith("/tmp/");
11976
+ }
11977
+ var ARTIFACT_DENIED = "Publish deliverables on the card with upload_attachment; the Artifact tool is not available in Conveyor sessions.";
11468
11978
  var READ_ONLY_BLOCKED_BASH = [
11469
11979
  { name: "git commit", re: /\bgit\s+(?:-\S+\s+)*commit\b/ },
11470
11980
  { name: "git push", re: /\bgit\s+(?:-\S+\s+)*push\b/ },
@@ -11483,17 +11993,19 @@ function matchesReadOnlyBlocked(cmd) {
11483
11993
  }
11484
11994
  var POST_CHANNEL_TOOL = /(^|__)post_channel_message$/;
11485
11995
  var CHANNEL_POST_DENIED = "Posting into a chat channel is not available in this mode. Reading channels still is \u2014 use read_channel_messages if you need the discussion.";
11996
+ var DECISION_SETTLE_TOOLS = /(^|__)(resolve_decision|update_decision)$/;
11997
+ var DECISION_SETTLE_DENIED = "Settling a decision is not available in review mode. Say on the PR that the change turns on an unsettled decision, and cite it with list_decisions \u2014 a reviewer does not make the product call.";
11486
11998
  function handleReadOnlyToolAccess(toolName, input) {
11487
11999
  if (POST_CHANNEL_TOOL.test(toolName)) {
11488
12000
  return { behavior: "deny", message: CHANNEL_POST_DENIED };
11489
12001
  }
11490
12002
  if (PM_PLAN_FILE_TOOLS.has(toolName)) {
11491
- if (isPlanFile(input)) {
12003
+ if (isPlanFile(input) || isScratchPath(input)) {
11492
12004
  return { behavior: "allow", updatedInput: input };
11493
12005
  }
11494
12006
  return {
11495
12007
  behavior: "deny",
11496
- message: "Discovery mode is read-only. File writes are restricted to plan files."
12008
+ message: "Discovery mode is read-only. File writes are restricted to plan files and /tmp/ scratch files \u2014 write a deliverable under /tmp/ and attach it with upload_attachment."
11497
12009
  };
11498
12010
  }
11499
12011
  if (toolName === "Bash") {
@@ -11532,6 +12044,9 @@ function handleReviewToolAccess(toolName, input) {
11532
12044
  if (POST_CHANNEL_TOOL.test(toolName)) {
11533
12045
  return { behavior: "deny", message: CHANNEL_POST_DENIED };
11534
12046
  }
12047
+ if (DECISION_SETTLE_TOOLS.test(toolName)) {
12048
+ return { behavior: "deny", message: DECISION_SETTLE_DENIED };
12049
+ }
11535
12050
  return handleBuildingToolAccess(toolName, input);
11536
12051
  }
11537
12052
  function handleAutoToolAccess(toolName, input, hasExitedPlanMode, isParentTask) {
@@ -11649,7 +12164,7 @@ var DENIAL_FORCE_STOP_THRESHOLD = 8;
11649
12164
  function handleDenialEscalation(host, consecutiveDenials) {
11650
12165
  if (consecutiveDenials === DENIAL_WARNING_THRESHOLD) {
11651
12166
  host.connection.postChatMessage(
11652
- `\u26A0\uFE0F Multiple tool denials detected. You are in ${host.agentMode} mode \u2014 file writes outside .claude/plans/ are not permitted. Focus on creating a plan instead of implementing code changes.`
12167
+ `\u26A0\uFE0F Multiple tool denials detected. You are in ${host.agentMode} mode \u2014 file writes outside .claude/plans/ and /tmp/ are not permitted. Focus on creating a plan instead of implementing code changes.`
11653
12168
  );
11654
12169
  }
11655
12170
  if (consecutiveDenials >= DENIAL_FORCE_STOP_THRESHOLD) {
@@ -11695,6 +12210,9 @@ function checkToolLoop(host, toolName, input) {
11695
12210
  return { behavior: "deny", message: buildRepeatLoopMessage(repeats, isHeavyGateActive()) };
11696
12211
  }
11697
12212
  function resolveToolAccess(host, toolName, input) {
12213
+ if (toolName === "Artifact") {
12214
+ return { behavior: "deny", message: ARTIFACT_DENIED };
12215
+ }
11698
12216
  switch (host.agentMode) {
11699
12217
  case "discovery":
11700
12218
  case "help":
@@ -13409,6 +13927,100 @@ var BackgroundWorkTracker = class {
13409
13927
  }
13410
13928
  };
13411
13929
 
13930
+ // src/runner/scheduled-wakeup.ts
13931
+ var SCHEDULE_WAKEUP_TOOL = "schedulewakeup";
13932
+ var SCHEDULED_WAKEUP_MIN_DELAY_SECONDS = 60;
13933
+ var SCHEDULED_WAKEUP_MAX_DELAY_SECONDS = 3600;
13934
+ var SCHEDULED_WAKEUP_GRACE_MS = 2 * 60 * 1e3;
13935
+ function asRecord3(input) {
13936
+ if (typeof input === "string") {
13937
+ try {
13938
+ const parsed = JSON.parse(input);
13939
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
13940
+ } catch {
13941
+ return null;
13942
+ }
13943
+ }
13944
+ return typeof input === "object" && input !== null ? input : null;
13945
+ }
13946
+ function parseScheduledWakeup(tool2, input, now = Date.now()) {
13947
+ if (tool2.toLowerCase() !== SCHEDULE_WAKEUP_TOOL) return null;
13948
+ const record = asRecord3(input);
13949
+ if (!record) return null;
13950
+ if (record.stop === true) return null;
13951
+ const raw = record.delaySeconds;
13952
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
13953
+ const delaySeconds = Math.min(
13954
+ SCHEDULED_WAKEUP_MAX_DELAY_SECONDS,
13955
+ Math.max(SCHEDULED_WAKEUP_MIN_DELAY_SECONDS, raw)
13956
+ );
13957
+ return { delaySeconds, deadline: now + delaySeconds * 1e3 + SCHEDULED_WAKEUP_GRACE_MS };
13958
+ }
13959
+ function isScheduledWakeupStop(tool2, input) {
13960
+ if (tool2.toLowerCase() !== SCHEDULE_WAKEUP_TOOL) return false;
13961
+ return asRecord3(input)?.stop === true;
13962
+ }
13963
+ var ScheduledWakeupTracker = class {
13964
+ /** Absolute expiry (epoch ms) of the armed wakeup; null when none. */
13965
+ deadline = null;
13966
+ onChange;
13967
+ log;
13968
+ constructor(options = {}) {
13969
+ this.onChange = options.onChange;
13970
+ this.log = options.log ?? ((message) => process.stderr.write(message));
13971
+ }
13972
+ /**
13973
+ * Feed one `tool_use` from the turn stream. Returns true when it changed the
13974
+ * armed state (test/diagnostic signal; callers can ignore it).
13975
+ */
13976
+ noteToolUse(tool2, input, now = Date.now()) {
13977
+ if (isScheduledWakeupStop(tool2, input)) {
13978
+ if (this.deadline === null) return false;
13979
+ this.deadline = null;
13980
+ this.log("[conveyor-agent] scheduled wakeup stopped \u2014 heartbeats may report idle again\n");
13981
+ this.onChange?.();
13982
+ return true;
13983
+ }
13984
+ const arm = parseScheduledWakeup(tool2, input, now);
13985
+ if (!arm) return false;
13986
+ this.deadline = arm.deadline;
13987
+ this.log(
13988
+ `[conveyor-agent] scheduled wakeup armed (${arm.delaySeconds}s) \u2014 heartbeats stay non-idle until it fires
13989
+ `
13990
+ );
13991
+ this.onChange?.();
13992
+ return true;
13993
+ }
13994
+ /** Is a non-expired wakeup armed? */
13995
+ hasPending(now = Date.now()) {
13996
+ this.prune(now);
13997
+ return this.deadline !== null;
13998
+ }
13999
+ /** Absolute expiry of the armed wakeup, or null when none is armed. Callers
14000
+ * bound their own deferrals by this rather than by a fixed cap — a one-hour
14001
+ * arm outlives `BACKGROUND_WORK_MAX_MS`. */
14002
+ pendingDeadline(now = Date.now()) {
14003
+ this.prune(now);
14004
+ return this.deadline;
14005
+ }
14006
+ /** Drop the arm (a turn started, or the session is shutting down). */
14007
+ clear() {
14008
+ if (this.deadline === null) return;
14009
+ this.deadline = null;
14010
+ this.onChange?.();
14011
+ }
14012
+ /** Expire a deadline that has passed, loudly — the wakeup should have fired
14013
+ * a turn by now, so a tripped expiry is worth seeing in the log. */
14014
+ prune(now) {
14015
+ if (this.deadline === null || this.deadline > now) return;
14016
+ this.deadline = null;
14017
+ this.log(
14018
+ "[conveyor-agent] scheduled wakeup deadline passed with no turn \u2014 reporting idle again\n"
14019
+ );
14020
+ this.onChange?.();
14021
+ }
14022
+ };
14023
+
13412
14024
  // src/runner/live-children.ts
13413
14025
  function findLiveChild(sources) {
13414
14026
  for (const source of sources) {
@@ -13476,6 +14088,13 @@ var SessionRunner = class _SessionRunner {
13476
14088
  backgroundWork = new BackgroundWorkTracker({
13477
14089
  onChange: () => this.refreshLoopStatus()
13478
14090
  });
14091
+ /** An armed `ScheduleWakeup` (the dynamic `/loop` mode): the turn ended, but
14092
+ * the agent asked to be re-invoked at a deadline. Same liveness contract as
14093
+ * `backgroundWork` — while it reports pending, heartbeats go out non-idle so
14094
+ * the reconciler cannot sleep the pod before the wakeup fires. */
14095
+ scheduledWakeup = new ScheduledWakeupTracker({
14096
+ onChange: () => this.refreshLoopStatus()
14097
+ });
13479
14098
  /** Boot supervisor handle, set post-construction once it's known (it is
13480
14099
  * started alongside connect(), before the runner itself exists in some
13481
14100
  * call sites) — notified when the core loop goes live so it can release
@@ -13520,6 +14139,7 @@ var SessionRunner = class _SessionRunner {
13520
14139
  },
13521
14140
  onIdleTimeout: () => {
13522
14141
  if (this.deferShutdownForLiveChild("idle")) return;
14142
+ if (this.deferShutdownForScheduledWakeup("idle")) return;
13523
14143
  if (this.deferShutdownForBackgroundWork()) return;
13524
14144
  process.stderr.write("[conveyor-agent] Idle timeout reached, stopping agent\n");
13525
14145
  this.stopped = true;
@@ -13532,6 +14152,7 @@ var SessionRunner = class _SessionRunner {
13532
14152
  },
13533
14153
  onDormantTimeout: () => {
13534
14154
  if (this.deferShutdownForLiveChild("dormant")) return;
14155
+ if (this.deferShutdownForScheduledWakeup("dormant")) return;
13535
14156
  process.stderr.write("[conveyor-agent] Dormant idle timeout reached, shutting down\n");
13536
14157
  this.stopped = true;
13537
14158
  this.queryBridge?.stop();
@@ -13591,6 +14212,50 @@ var SessionRunner = class _SessionRunner {
13591
14212
  const label = timer === "idle" ? "Idle" : "Dormant idle";
13592
14213
  process.stderr.write(
13593
14214
  `[conveyor-agent] ${label} timeout deferred: ${live.kind} session ${live.sessionId} active
14215
+ `
14216
+ );
14217
+ if (timer === "idle") {
14218
+ this.lifecycle.startIdleTimer(recheckMs);
14219
+ } else {
14220
+ this.dormantDeadline = Date.now() + recheckMs;
14221
+ this.lifecycle.startDormantTimer(recheckMs);
14222
+ }
14223
+ return true;
14224
+ }
14225
+ /**
14226
+ * Suppress an idle or dormant shutdown while a `ScheduleWakeup` is armed.
14227
+ *
14228
+ * Both timers matter. The idle timer (30 min) kills a loop that paces itself
14229
+ * slower than that, and the dormant timer (60 min after a `completed` event)
14230
+ * kills a one-hour arm — the agent declares completion at the end of every
14231
+ * tick, so a dynamic loop sits dormant by construction.
14232
+ *
14233
+ * The bound is the WAKEUP'S OWN deadline, not `BACKGROUND_WORK_MAX_MS`: the
14234
+ * tool clamps `delaySeconds` to one hour, which already exceeds that 45-min
14235
+ * cap, so reusing it would shut the pod down before the longest legal arm
14236
+ * fired. Once the deadline passes the tracker expires it and this returns
14237
+ * false, so a lost timer cannot pin a pod.
14238
+ */
14239
+ deferShutdownForScheduledWakeup(timer) {
14240
+ let deadline;
14241
+ try {
14242
+ deadline = this.scheduledWakeup.pendingDeadline();
14243
+ } catch (err) {
14244
+ process.stderr.write(
14245
+ `[conveyor-agent] Scheduled-wakeup probe failed, not deferring: ${err}
14246
+ `
14247
+ );
14248
+ return false;
14249
+ }
14250
+ if (deadline === null) return false;
14251
+ const remainingMs = Math.max(0, deadline - Date.now());
14252
+ const recheckMs = Math.min(
14253
+ Math.max(remainingMs, 1),
14254
+ Math.max(this.lifecycle.config.idleTimeoutMs, 1)
14255
+ );
14256
+ const label = timer === "idle" ? "Idle" : "Dormant idle";
14257
+ process.stderr.write(
14258
+ `[conveyor-agent] ${label} timeout deferred: scheduled wakeup armed (${Math.round(remainingMs / 1e3)}s remaining)
13594
14259
  `
13595
14260
  );
13596
14261
  if (timer === "idle") {
@@ -14520,6 +15185,7 @@ var SessionRunner = class _SessionRunner {
14520
15185
  });
14521
15186
  this.portDiscovery.stop();
14522
15187
  this.backgroundWork.clear();
15188
+ this.scheduledWakeup.clear();
14523
15189
  this.loopLag.stop();
14524
15190
  this.lifecycle.destroy();
14525
15191
  this.connection.disconnect();
@@ -14760,6 +15426,7 @@ ${outcome.failures.join("\n")}
14760
15426
  if (this.questionPendingSince !== null && Date.now() - this.questionPendingSince < resolveQuestionGraceMs() + QUESTION_HOLD_SLACK_MS) {
14761
15427
  return "waiting";
14762
15428
  }
15429
+ if (this.scheduledWakeup.hasPending()) return "waiting";
14763
15430
  return this.backgroundWork.hasPending() || isHeavyGateActive() ? "waiting" : "idle";
14764
15431
  }
14765
15432
  // ── Question grace hold ────────────────────────────────────────────
@@ -14830,11 +15497,12 @@ ${outcome.failures.join("\n")}
14830
15497
  this.loopLag.setStatus(loopStatus);
14831
15498
  return loopStatus;
14832
15499
  }
14833
- /** One `tool_use` from the turn stream — registers a background launch when
14834
- * the tool starts work that outlives the turn. */
15500
+ /** One `tool_use` from the turn stream — registers work that outlives the
15501
+ * turn: a background launch, or an armed `ScheduleWakeup`. */
14835
15502
  noteToolUseForBackgroundWork(event) {
14836
15503
  if (event.type !== "tool_use" || typeof event.tool !== "string") return;
14837
15504
  this.backgroundWork.noteToolUse(event.tool, event.input);
15505
+ this.scheduledWakeup.noteToolUse(event.tool, event.input);
14838
15506
  }
14839
15507
  async setState(status) {
14840
15508
  if (status === "running") {
@@ -14875,6 +15543,7 @@ ${outcome.failures.join("\n")}
14875
15543
  /** Mark a fresh turn without emitting a duplicate status. QueryExecutor's
14876
15544
  * prefilled PTY callback already emitted `running` before it reaches here. */
14877
15545
  beginRunningEpisode() {
15546
+ this.scheduledWakeup.clear();
14878
15547
  this.activeEpisodeStartedAt = Date.now();
14879
15548
  this.backgroundDeferStartedAt = null;
14880
15549
  this.backgroundWakeFiredThisIdle = false;
@@ -14886,6 +15555,7 @@ ${outcome.failures.join("\n")}
14886
15555
  this.clearQuestionHold();
14887
15556
  this.portDiscovery.stop();
14888
15557
  this.backgroundWork.clear();
15558
+ this.scheduledWakeup.clear();
14889
15559
  this.loopLag.stop();
14890
15560
  this.lifecycle.destroy();
14891
15561
  try {