toolroll 0.9.84 → 0.9.86

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 (132) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +9 -5
  4. package/dist/agent-fence.js +6 -5
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment.js +4 -3
  8. package/dist/browser/workspace.css +1 -1
  9. package/dist/browser/workspace.js +33 -31
  10. package/dist/browser-workspace.d.ts +45 -0
  11. package/dist/chat-actions.d.ts +11 -1
  12. package/dist/chat-actions.js +53 -1
  13. package/dist/chat-channel.d.ts +10 -1
  14. package/dist/chat-channel.js +36 -5
  15. package/dist/chat-decide.d.ts +16 -4
  16. package/dist/chat-decide.js +239 -31
  17. package/dist/chat-messages.d.ts +5 -0
  18. package/dist/chat-messages.js +1 -1
  19. package/dist/chat-quiet.js +6 -4
  20. package/dist/chat-review.d.ts +4 -0
  21. package/dist/chat-review.js +13 -4
  22. package/dist/chat-terms.d.ts +9 -0
  23. package/dist/chat-terms.js +46 -0
  24. package/dist/claim.d.ts +3 -0
  25. package/dist/claim.js +4 -1
  26. package/dist/contracts/chat-actions.d.ts +9 -0
  27. package/dist/contracts/chat-actions.js +3 -0
  28. package/dist/contracts/console-api.d.ts +6 -2
  29. package/dist/contracts/console-api.js +6 -2
  30. package/dist/contracts/flow-propose.d.ts +4 -1
  31. package/dist/contracts/flow-propose.js +1 -1
  32. package/dist/contracts/flow.d.ts +41 -0
  33. package/dist/contracts/flow.js +4 -2
  34. package/dist/contracts/lead-tools.d.ts +51 -0
  35. package/dist/contracts/lead-tools.js +5 -0
  36. package/dist/contracts/store-json.d.ts +2 -0
  37. package/dist/contracts/trigger-payloads.d.ts +20 -0
  38. package/dist/contracts/trigger-payloads.js +4 -0
  39. package/dist/contracts/youiyoux-request.d.ts +248 -0
  40. package/dist/contracts/youiyoux-request.js +152 -0
  41. package/dist/coordinator.js +1 -0
  42. package/dist/deploy-gate.js +4 -0
  43. package/dist/dispatch.d.ts +3 -0
  44. package/dist/dispatch.js +4 -1
  45. package/dist/flow-browser.d.ts +103 -0
  46. package/dist/flow-browser.js +204 -0
  47. package/dist/flow-cadence.d.ts +8 -2
  48. package/dist/flow-cadence.js +16 -3
  49. package/dist/flow-card-task.d.ts +9 -0
  50. package/dist/flow-card-task.js +15 -6
  51. package/dist/flow-code.d.ts +5 -1
  52. package/dist/flow-code.js +4 -1
  53. package/dist/flow-engine.js +16 -0
  54. package/dist/flow-live.js +9 -1
  55. package/dist/flow-pull-request.js +15 -7
  56. package/dist/flow-share.js +3 -1
  57. package/dist/flow-steps.d.ts +9 -1
  58. package/dist/flow-steps.js +55 -5
  59. package/dist/flow-triggers.js +55 -0
  60. package/dist/flows-cli.d.ts +19 -0
  61. package/dist/flows-cli.js +47 -2
  62. package/dist/flows-ui.js +11 -2
  63. package/dist/flows.js +5 -2
  64. package/dist/integrations-ui.js +9 -4
  65. package/dist/integrations.d.ts +9 -1
  66. package/dist/integrations.js +43 -2
  67. package/dist/landed-cleanup.d.ts +4 -0
  68. package/dist/landed-cleanup.js +3 -2
  69. package/dist/lead-capabilities.js +2 -1
  70. package/dist/lead-cli.js +3 -1
  71. package/dist/lead-tools.d.ts +2 -0
  72. package/dist/lead-tools.js +65 -10
  73. package/dist/lead-voice.js +16 -5
  74. package/dist/lead.d.ts +2 -0
  75. package/dist/lead.js +3 -3
  76. package/dist/operate.d.ts +2 -3
  77. package/dist/operate.js +226 -21
  78. package/dist/project-activity.d.ts +20 -0
  79. package/dist/project-activity.js +23 -0
  80. package/dist/project-cli.d.ts +1 -0
  81. package/dist/project-cli.js +36 -19
  82. package/dist/project-delete.js +7 -3
  83. package/dist/project-knowledge.d.ts +3 -3
  84. package/dist/project-outcomes.d.ts +119 -0
  85. package/dist/project-outcomes.js +79 -0
  86. package/dist/pull-request-flow.js +35 -4
  87. package/dist/result-actions.d.ts +4 -0
  88. package/dist/result-actions.js +41 -9
  89. package/dist/result-follow-ups.js +4 -0
  90. package/dist/result-review.d.ts +9 -0
  91. package/dist/result-review.js +45 -5
  92. package/dist/scope.d.ts +5 -0
  93. package/dist/scope.js +7 -0
  94. package/dist/server/chat.js +4 -2
  95. package/dist/server/console.css +41 -0
  96. package/dist/server/flows.js +15 -0
  97. package/dist/server/pages.js +11 -1
  98. package/dist/server/render-chat.js +3 -2
  99. package/dist/server/render-pages.d.ts +15 -2
  100. package/dist/server/render-pages.js +52 -4
  101. package/dist/server/render-results.d.ts +2 -0
  102. package/dist/server/render-results.js +20 -7
  103. package/dist/server/render-tasks.d.ts +9 -0
  104. package/dist/server/render-tasks.js +32 -11
  105. package/dist/server/route-table.js +12 -0
  106. package/dist/server/settings.js +18 -1
  107. package/dist/server/task-views.js +11 -0
  108. package/dist/server/tasks.d.ts +10 -0
  109. package/dist/server/tasks.js +95 -7
  110. package/dist/settings-proofs.js +1 -0
  111. package/dist/store.d.ts +116 -5
  112. package/dist/store.js +385 -24
  113. package/dist/surface.js +18 -2
  114. package/dist/task-archive.d.ts +10 -0
  115. package/dist/task-archive.js +16 -0
  116. package/dist/task-control.d.ts +1 -1
  117. package/dist/task-source.d.ts +13 -1
  118. package/dist/task-source.js +17 -1
  119. package/dist/telegram-lead.js +7 -5
  120. package/dist/telegram.js +1 -1
  121. package/dist/tidy.d.ts +130 -0
  122. package/dist/tidy.js +503 -0
  123. package/dist/toolroll-update.js +2 -2
  124. package/dist/webhooks.d.ts +6 -0
  125. package/dist/webhooks.js +25 -0
  126. package/dist/work-index.d.ts +28 -0
  127. package/dist/work-index.js +82 -28
  128. package/dist/workspace-ui.d.ts +1 -1
  129. package/dist/workspace-ui.js +3 -2
  130. package/dist/youiyoux-intake.d.ts +234 -0
  131. package/dist/youiyoux-intake.js +908 -0
  132. package/package.json +11 -8
@@ -36,7 +36,9 @@ import { claudeDraftRunner, DRAFT_CHARS, DRAFT_TIMEOUT, draftPrompt, wholeDraft
36
36
  import { overruns, passOn, TEXT_LIMITS, writeWithin } from "./text-limits.js";
37
37
  import { readFlowSecrets, sendingReady, runRequest, sendEmail, toolWaiting, useTool } from "./flow-actions.js";
38
38
  import { cleanFolder, flowLaunch, runCode } from "./flow-code.js";
39
+ import { browserStanding } from "./flow-browser.js";
39
40
  import { readProviderKey } from "./keys.js";
41
+ import { cardTaskArchived } from "./task-archive.js";
40
42
  import { replyInChannel } from "./chat-inbox.js";
41
43
  import { recordSent, threadOf } from "./flow-replies.js";
42
44
  import { ASKED, subagentReady, subagentTurn } from "./subagent-work.js";
@@ -70,6 +72,9 @@ export async function runFlowSteps(store, repo, now, io) {
70
72
  const stage = known.definition?.stages.find(one => one.id === card.stage);
71
73
  if (stage === undefined)
72
74
  continue;
75
+ // A card whose task is put away (v145) runs nothing — no step, subagent turn or pull request — until it's restored.
76
+ if (cardTaskArchived(store, card.id))
77
+ continue;
73
78
  // v92: a subagent's turn — a zone it handles, or a decision it staffs (a person decides when it's paused or gone).
74
79
  if (stage.kind === "subagent" || (stage.kind === "approval" && stage.subagent !== undefined)) {
75
80
  await subagentStep(store, known.flow, known.definition, stage, card, now, io, pass);
@@ -145,6 +150,14 @@ export async function runFlowSteps(store, repo, now, io) {
145
150
  store.updateFlowCard(card.id, { waiting }, now);
146
151
  continue;
147
152
  }
153
+ // A script that starts a browser waits, saying so, until a person approves browser use for the flow (flow-browser.ts),
154
+ // rather than run and crash inside the sandbox; it runs on its own once approved.
155
+ const browser = stage.kind === "check" && stage.browser === true ? (script === null || known.definition === null ? { allowed: false, said: "This zone's script can't be read." } : browserStanding(store, known.flow.id, stage.id, { definition: known.definition, script })) : null;
156
+ if (browser !== null && !browser.allowed) {
157
+ if (card.waiting !== browser.said)
158
+ store.updateFlowCard(card.id, { waiting: browser.said }, now);
159
+ continue;
160
+ }
148
161
  if (!claimStep(store, card, { stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
149
162
  continue;
150
163
  pass.ran++;
@@ -174,12 +187,21 @@ export async function runFlowSteps(store, repo, now, io) {
174
187
  }
175
188
  /** Claim this card's visit to its step zone, only while the card is still there on that visit: a pass that read the card
176
189
  * before another pass (or a person) moved it never runs the step for a visit that's over. */
177
- function claimStep(store, card, step, now) {
190
+ export function claimStep(store, card, step, now) {
178
191
  return store.transact(() => {
179
192
  const fresh = store.getFlowCard(card.id);
180
193
  if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== step.stage)
181
194
  return false;
182
- return store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now);
195
+ // A card whose task is put away (v145) runs no step until the task is restored (both of its tasks, re-read here).
196
+ if (cardTaskArchived(store, card.id))
197
+ return false;
198
+ if (!store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now))
199
+ return false;
200
+ // The tasks this step was admitted for stay held while it runs, wherever the card moves meanwhile.
201
+ for (const task of [fresh.task, fresh.primaryTask])
202
+ if (task !== null)
203
+ store.handle.prepare("INSERT OR IGNORE INTO flow_step_task (card, entry, task_id) VALUES (?, ?, ?)").run(card.id, card.entry, task);
204
+ return true;
183
205
  });
184
206
  }
185
207
  /** One subagent turn, when one is due for this card: claimed like any step, retried on failure, and waiting while its question is open. */
@@ -361,16 +383,31 @@ async function runCheck(store, flow, stage, script, card, now, io) {
361
383
  if (added.code !== 0)
362
384
  return { state: "retry", said: `Couldn't make a copy of${where}: ${added.stderr.trim().split("\n")[0]?.slice(0, 160) ?? "git refused"}.` };
363
385
  }
364
- const launch = flowLaunch(store, { repo: flow.repo, folder: path, scratch: io.scratch, copy, policy: store.flowFencePolicy(flow.id), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }), ...(io.home === undefined ? {} : { home: io.home }) });
386
+ // Browser use, proved at the moment of launch: a zone that says it starts a browser runs only with a person's standing
387
+ // approval (flow-browser.ts); then its script, and only its script, gets Chromium's one service.
388
+ const wantsBrowser = stage.browser === true;
389
+ // Read once: the drawing and the script this launch runs are the very objects the browser decision is about.
390
+ const running = flowDefinitionOf(flow);
391
+ const standing = !wantsBrowser ? null : running === null || running.stages.find(one => one.id === stage.id) === undefined || JSON.stringify(running.stages.find(one => one.id === stage.id)) !== JSON.stringify(stage)
392
+ ? { allowed: false, said: "This zone changed while it was starting, so it didn't run." } : browserStanding(store, flow.id, stage.id, { definition: running, script });
393
+ if (standing !== null && !standing.allowed) {
394
+ if (copy)
395
+ await io.git("git", ["-C", flow.repo, "worktree", "remove", "--force", path], { timeoutMs: 60_000 }).catch(() => undefined);
396
+ rmSync(path, { recursive: true, force: true });
397
+ return { state: "failed", said: standing.said };
398
+ }
399
+ const launch = flowLaunch(store, { repo: flow.repo, folder: path, scratch: io.scratch, copy, policy: store.flowFencePolicy(flow.id), ...(standing?.allowed === true ? { browser: true } : {}), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }), ...(io.home === undefined ? {} : { home: io.home }) });
365
400
  try {
366
401
  if (launch !== null && "problem" in launch)
367
402
  return { state: "failed", said: launch.problem };
403
+ // The project's setup never gets the browser rule: only the zone's script asked for it.
404
+ const setupFence = launch?.fence === undefined ? undefined : { ...launch.fence, fenceGrants: withoutBrowser(launch.fence.fenceGrants) };
368
405
  let log = "";
369
406
  const setup = copy ? store.liveWorktreeSetup(flow.repo) : null;
370
407
  if (setup !== null) {
371
408
  const shell = approvedCommandShell(setup.command);
372
409
  const prepared = await io.shell(shell.file, shell.args, { cwd: path, timeoutMs: setup.timeoutMs, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST, processGroup: true,
373
- ...(launch === null ? {} : { env: launch.env, ...(launch.fence ?? {}) }) });
410
+ ...(launch === null ? {} : { env: launch.env, ...(setupFence ?? {}) }) });
374
411
  log += `$ ${setup.command}\n${prepared.stdout}${prepared.stderr}\n`;
375
412
  if (prepared.code !== 0)
376
413
  return { state: "failed", said: `The project's setup failed before ${script.name} ran (exit ${prepared.code}).\n${tail(`${prepared.stdout}\n${prepared.stderr}`)}`, log, exitCode: prepared.code };
@@ -385,7 +422,15 @@ async function runCheck(store, flow, stage, script, card, now, io) {
385
422
  answers: (stage.routes ?? []).map(one => one.answer),
386
423
  };
387
424
  const env = { FLOW_NAME: flow.name, FLOW_CARD_ID: String(card.id), FLOW_CARD_TITLE: card.title, FLOW_COMMIT: commit ?? "", FLOW_SCRIPT: script.name, FLOW_PROJECT: flow.repo };
388
- const ran = await runCode({ script, cwd: path, root: copy ? path : flow.repo, input, env, secrets, scratch: io.scratch, shell: io.shell, launch });
425
+ // Checked again right before the script: the flow or its script may have changed while the setup ran.
426
+ if (wantsBrowser) {
427
+ const still = running === null ? { allowed: false, said: "This flow's drawing can't be read." } : browserStanding(store, flow.id, stage.id, { definition: running, script });
428
+ if (!still.allowed)
429
+ return { state: "failed", said: still.said, log };
430
+ }
431
+ // And at every spawn attempt (a retry after a failed spawn too): revoked or changed meanwhile, nothing starts.
432
+ const stillApproved = !wantsBrowser || running === null ? undefined : () => browserStanding(store, flow.id, stage.id, { definition: running, script }).allowed;
433
+ const ran = await runCode({ script, cwd: path, root: copy ? path : flow.repo, input, env, secrets, scratch: io.scratch, shell: io.shell, launch, ...(stillApproved === undefined ? {} : { beforeSpawn: stillApproved }) });
389
434
  log += ran.log;
390
435
  const output = ran.output === "" ? undefined : ran.output;
391
436
  // Where it ran goes at the end of the first line: "run-tests failed (exit 1) on main."
@@ -407,6 +452,11 @@ async function runCheck(store, flow, stage, script, card, now, io) {
407
452
  rmSync(path, { recursive: true, force: true });
408
453
  }
409
454
  }
455
+ /** A fence's grants without Chromium's service (a flow's setup, beside a script zone that has it). */
456
+ function withoutBrowser(grants) {
457
+ const { browser: _scriptOnly, ...rest } = grants;
458
+ return rest;
459
+ }
410
460
  /** An update: a comment on the issue the card came from, and closing it if the zone says so. */
411
461
  async function updateSource(store, stage, card, io, now) {
412
462
  const source = card.source;
@@ -49,6 +49,7 @@ import { readFlowSecrets } from "./flow-secrets.js";
49
49
  import { prepareUnattendedCandidates, grantSignsScriptOf, scriptProvenanceOf, scriptRanDigestOf } from "./flow-unattended.js";
50
50
  import { tmpdir } from "node:os";
51
51
  import { envValue } from "./names.js";
52
+ import { recordBranchCheckRun, unprovenNoticeRuns } from "./tidy.js";
52
53
  import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
53
54
  import { isRecord } from "./is-record.js";
54
55
  export { FLOW_TRIGGER_KINDS };
@@ -925,6 +926,9 @@ async function checkTrigger(store, trigger, config, now, io) {
925
926
  store.updateFlowTrigger(trigger.id, { failures, lastAt: now.toISOString(), lastOutcome: fetched.problem, nextAt: new Date(now.getTime() + BACKOFF_MS[Math.min(failures, BACKOFF_MS.length) - 1]).toISOString() }, now);
926
927
  return { ok: false, said: fetched.problem, added: 0 };
927
928
  }
929
+ // Tidy's CI evidence (task-tidy.md): the watched branch's completed runs, green and red, beside the failures it carded.
930
+ if (config.kind === "github" && config.watch === "checks")
931
+ await observeBranchRuns(store, trigger, config, io.gh, now);
928
932
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Email";
929
933
  // Known items are left out before the batch is taken (v126): a page of things already received can't keep the unseen
930
934
  // ones behind it waiting.
@@ -1206,6 +1210,14 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1206
1210
  : config.watch === "pulls" && event === "pull_request" && ["opened", "ready_for_review", "labeled", "reopened"].includes(action) ? githubItem(config, body.pull_request, "pull")
1207
1211
  : config.watch === "checks" && event === "workflow_run" && action === "completed" ? githubRunItem(config, body.workflow_run) : null;
1208
1212
  items = found === null ? [] : [found];
1213
+ // A signed workflow run on the watched branch is tidy's CI evidence too (task-tidy.md), green or red: recorded only
1214
+ // inside `written`, which re-proves the trigger is still active with the same config, credential and address.
1215
+ if (config.watch === "checks" && config.branch !== null && event === "workflow_run" && action === "completed") {
1216
+ const branch = config.branch;
1217
+ const recorded = written(() => { recordBranchCheckRun(store, { repo: config.repo, branch }, readGithubRun(body.workflow_run), now); return { status: 200, said: "Recorded." }; });
1218
+ if (recorded.status !== 200)
1219
+ return recorded;
1220
+ }
1209
1221
  }
1210
1222
  else if (config.kind === "linear") {
1211
1223
  const body = readLinearEvent(payload), sent = Number(body.webhookTimestamp);
@@ -1648,3 +1660,46 @@ export function receiveFlowForm(store, token, fields, now) {
1648
1660
  store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: "Someone sent the form." }, now);
1649
1661
  return { status: 200, html: thanks };
1650
1662
  }
1663
+ /** Tidy's CI evidence (docs/plans/task-tidy.md): the watched branch's recent completed workflow runs, read through the
1664
+ * person's own gh login. Recorded only while the trigger still watches exactly that repository and branch once GitHub
1665
+ * answered; any failure records nothing (tidy then keeps the notices). */
1666
+ async function observeBranchRuns(store, trigger, config, gh, now) {
1667
+ if (config.branch === null)
1668
+ return;
1669
+ const branch = config.branch;
1670
+ let result;
1671
+ try {
1672
+ result = await gh("gh", ["api", "-H", "Accept: application/vnd.github+json", `repos/${config.repo}/actions/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=50`], { timeoutMs: 20_000 });
1673
+ }
1674
+ catch {
1675
+ return;
1676
+ }
1677
+ if (result.code !== 0)
1678
+ return;
1679
+ let body;
1680
+ try {
1681
+ body = JSON.parse(result.stdout);
1682
+ }
1683
+ catch {
1684
+ return;
1685
+ }
1686
+ const runs = [...readGithubRunList(body).workflow_runs ?? []];
1687
+ // A few older failure cards' own runs, so tidy can tell whether each was a push to this branch (task-tidy.md).
1688
+ for (const id of unprovenNoticeRuns(store, trigger.id, config.repo)) {
1689
+ try {
1690
+ const one = await gh("gh", ["api", "-H", "Accept: application/vnd.github+json", `repos/${config.repo}/actions/runs/${id}`], { timeoutMs: 20_000 });
1691
+ if (one.code === 0)
1692
+ runs.push(readGithubRun(JSON.parse(one.stdout)));
1693
+ }
1694
+ catch { /* read again next time */ }
1695
+ }
1696
+ // Re-proved and recorded in one transaction: a trigger paused or changed meanwhile records nothing.
1697
+ store.transact(() => {
1698
+ const current = store.getFlowTrigger(trigger.id);
1699
+ const still = current === null ? null : triggerConfigOf(current);
1700
+ if (current === null || current.state !== "active" || current.configJson !== trigger.configJson || still === null || still.kind !== "github" || still.watch !== "checks" || still.repo !== config.repo || still.branch !== branch)
1701
+ return;
1702
+ for (const run of runs)
1703
+ recordBranchCheckRun(store, { repo: config.repo, branch }, run, now);
1704
+ });
1705
+ }
@@ -296,6 +296,25 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
296
296
  readonly takesValue: true;
297
297
  readonly meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already';
298
298
  }];
299
+ }, {
300
+ readonly action: 'browser';
301
+ readonly synopsis: "let a flow's script zones marked browser: true start Chromium in their sandbox (on: a person approves with their own password, previews until --yes; off: any approver revokes)";
302
+ readonly mutation: 'identity-idempotent';
303
+ readonly audience: 'operator';
304
+ readonly positionals: readonly ['flow', 'on|off'];
305
+ readonly flags: readonly [...{
306
+ name: string;
307
+ takesValue: boolean;
308
+ meaning: string;
309
+ }[], {
310
+ name: string;
311
+ takesValue: boolean;
312
+ meaning: string;
313
+ }, {
314
+ readonly name: 'digest';
315
+ readonly takesValue: true;
316
+ readonly meaning: "required with --yes: the approval digest the preview showed; refuses if the flow or its scripts changed since";
317
+ }];
299
318
  }, {
300
319
  readonly action: 'archive';
301
320
  readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
package/dist/flows-cli.js CHANGED
@@ -19,6 +19,7 @@ import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, swi
19
19
  import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, setFlowTriggerOn, triggerConfigOf, validateTriggerConfig, rawTakesDeliveries } from './flow-triggers.js';
20
20
  import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
21
21
  import { prepareUnattendedCandidates, activeUnattendedGrant, parseUnattendedAsk, revokeUnattended, signUnattended, unattendedViewOf, unattendedWords, untilWords, UNATTENDED_MAX_RUNS } from './flow-unattended.js';
22
+ import { browserApprovalDigest, browserTerms, browserTermsOf, browserViewOf, revokeFlowBrowser, signFlowBrowser } from './flow-browser.js';
22
23
  import { MODE_MAX_DAYS } from './modes.js';
23
24
  import { verifyApproverByPassword } from './principal.js';
24
25
  import { authenticateApprover, proveCredential } from './scope.js';
@@ -71,6 +72,8 @@ export const FLOWS_DESCRIPTORS = [
71
72
  { name: 'budget', takesValue: true, meaning: 'optional dollar cap per attempt, like 5 or 2.50' },
72
73
  { name: 'runs', takesValue: true, meaning: `optional limit on runs a day (UTC), 1 to ${UNATTENDED_MAX_RUNS}` },
73
74
  { name: 'include', takesValue: true, meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already' }] },
75
+ { action: 'browser', synopsis: "let a flow's script zones marked browser: true start Chromium in their sandbox (on: a person approves with their own password, previews until --yes; off: any approver revokes)", mutation: 'identity-idempotent', audience: 'operator', positionals: ['flow', 'on|off'], flags: [...write, yes,
76
+ { name: 'digest', takesValue: true, meaning: "required with --yes: the approval digest the preview showed; refuses if the flow or its scripts changed since" }] },
74
77
  { action: 'archive', synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes', mutation: 'identity-idempotent', positionals: ['flow'], flags: [...write, yes] },
75
78
  ];
76
79
  /** `--file` is a value here (a path in the project), where elsewhere it is a switch; so are unattended's `--budget`, `--runs` and `--include`. */
@@ -160,10 +163,12 @@ export async function runFlowsCommand(positional, flags, context) {
160
163
  const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
161
164
  // A trigger from anyone widens what comes in (chat-management.md §0a.3): only the person's own proof counts, never a
162
165
  // machine credential standing in for them (a remote run's API token, a lead's token), though both pass the check above.
163
- const widening = (project) => {
166
+ // The same proof approves browser use in a flow's script zones (flow-browser.ts): the method is recorded with it.
167
+ const personMethod = (project) => {
164
168
  const proved = authenticateApprover(store, acting.name, acting.token, project);
165
- return proved.ok && personProof(proved.method) ? { widening: 'proved' } : {};
169
+ return proved.ok && personProof(proved.method) ? proved.method : null;
166
170
  };
171
+ const widening = (project) => personMethod(project) === null ? {} : { widening: 'proved' };
167
172
  const settle = (project) => { try {
168
173
  advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
169
174
  }
@@ -396,6 +401,42 @@ export async function runFlowsCommand(positional, flags, context) {
396
401
  `Revoke: toolroll flows unattended ${flow.id} off`,
397
402
  ]);
398
403
  }
404
+ if (action === 'browser') {
405
+ const flow = flowOf(args[0], reachable);
406
+ if (flow === null)
407
+ return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
408
+ const turn = args[1];
409
+ if (turn !== 'on' && turn !== 'off')
410
+ return fail('usage', `Use flows browser ${flow.id} on, or flows browser ${flow.id} off.`, EXIT.usage);
411
+ if (turn === 'off') {
412
+ // Lowering authority is one step for any approver on the project.
413
+ if (!revokeFlowBrowser(store, flow.id, who, now))
414
+ return refuse(flow.repo, 'not-on', `${flow.name} has no approved browser use. Nothing was changed.`);
415
+ record(flow.repo, 'accepted', `flow #${flow.id} browser off`);
416
+ return ok({ applied: true, browser: null }, [`${flow.name}'s scripts can no longer start a browser.`]);
417
+ }
418
+ if (flowDefinitionOf(flow) === null)
419
+ return refuse(flow.repo, 'unreadable', 'This flow\'s drawing can\'t be read. Save it again from the editor.');
420
+ const terms = browserTermsOf(store, flow);
421
+ if (terms === null || terms.zones.length === 0)
422
+ return refuse(flow.repo, 'refused', terms === null ? 'A zone that starts a browser has no script it can approve: missing, waiting for approval, or one that runs a project file (write the script on the zone instead). Nothing was changed.'
423
+ : `No zone in ${flow.name} says it starts a browser. Mark its script zone with browser: true (flows edit), then approve. Nothing was changed.`);
424
+ const digest = browserApprovalDigest(terms);
425
+ if (!flags.has('yes'))
426
+ return preview(flow.repo, `Approve browser use in the ${flow.name} flow`, [...browserTerms(terms.zones), `Approval digest ${digest}: approve exactly this with --yes --digest ${digest}.`], { flowId: flow.id, digest, zones: terms.zones });
427
+ // A person's own proof only: a lead's or remote run's token passes the approver check above but never approves this.
428
+ // The digest the person read is required: approving with --yes alone could approve something they never saw.
429
+ const given = text('digest');
430
+ if (given === null)
431
+ return fail('usage', `Approve exactly what you read: flows browser ${flow.id} on --yes --digest <the digest the preview showed>.`, EXIT.usage);
432
+ const signed = signFlowBrowser(store, { flow: flow.id, signer: who, proof: personMethod(flow.repo), sawDigest: given }, now);
433
+ if (!signed.ok)
434
+ return refuse(flow.repo, 'refused', `${signed.message} Nothing was changed.`);
435
+ record(flow.repo, 'accepted', `flow #${flow.id} browser on`);
436
+ return ok({ applied: true, browser: { digest: signed.grant.digest, zones: signed.grant.zones, signedBy: signed.grant.signedBy } }, [
437
+ `${flow.name} may start a browser. Approved by ${who}:`, ...browserTerms(signed.grant.zones).map(line => ` ${line}`), `Revoke: toolroll flows browser ${flow.id} off`,
438
+ ]);
439
+ }
399
440
  if (action === 'archive') {
400
441
  const cards = store.flowCards(flow.id, false).length, triggers = store.flowTriggers(flow.id).filter(one => one.state === 'active').length;
401
442
  if (!flags.has('yes'))
@@ -566,6 +607,7 @@ function zoneOf(stage, flow) {
566
607
  ...(stage.limit === undefined ? {} : { remindAfterMinutes: stage.limit.minutes, thenMoveTo: stage.limit.to }),
567
608
  ...(stage.kind === 'approval' ? { decider: deciderOf(stage, flow) } : {}),
568
609
  ...(stage.script === null ? {} : { script: stage.script }),
610
+ ...(stage.browser === true ? { browser: true } : {}),
569
611
  ...(stage.merge === undefined ? {} : { merge: stage.merge }),
570
612
  ...(stage.subagent === undefined ? {} : { subagent: stage.subagent }),
571
613
  ...(stage.options === undefined ? {} : { options: stage.options.map(one => ({ label: one.label, to: one.to })) }),
@@ -589,6 +631,8 @@ function describeFlow(store, flow, now) {
589
631
  }),
590
632
  cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
591
633
  unattended: unattendedJson(store, flow, now),
634
+ // Browser use, only for a flow with a zone that starts one: the same status line the flow page shows.
635
+ ...(() => { const view = browserViewOf(store, flow); return view === null ? {} : { browser: { status: view.status, words: view.words, signedBy: view.signedBy, signedAt: view.signedAt } }; })(),
592
636
  };
593
637
  }
594
638
  /** Whether a flow runs on its own, and until when: null when it doesn't. */
@@ -606,6 +650,7 @@ function showLines(flow) {
606
650
  ? `Runs on its own: no · turn on: toolroll flows unattended ${flow.id} on --days N`
607
651
  : `Runs on its own until ${untilWords(flow.unattended.until)} · revoke: toolroll flows unattended ${flow.id} off`,
608
652
  ...(flow.unattended === null ? [] : [` Covers: ${flow.unattended.covers.join('; ')}`]),
653
+ ...(flow.browser === undefined ? [] : [`${flow.browser.words} · ${flow.browser.status === 'allowed' ? `turn off: toolroll flows browser ${flow.id} off` : `allow: toolroll flows browser ${flow.id} on`}`]),
609
654
  '', 'Zones',
610
655
  ...flow.zones.flatMap((zone, index) => [
611
656
  ` ${index + 1}. ${zone.title} (${zone.id}) — ${zone.does}${zone.id === flow.start ? ' · start' : ''}`,
package/dist/flows-ui.js CHANGED
@@ -24,6 +24,7 @@ import { callWords, receiptWords } from "./subagent-tools.js";
24
24
  import { undoFor } from "./subagent-week.js";
25
25
  import { flowCardTaskLine } from "./flow-card-task.js";
26
26
  import { unattendedViewOf } from "./flow-unattended.js";
27
+ import { browserViewOf } from "./flow-browser.js";
27
28
  import { readFlowSecrets } from "./flow-secrets.js";
28
29
  import { html, joinHtml, postForm } from "./html.js";
29
30
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
@@ -114,6 +115,8 @@ const historyText = (event, title, sorts = new Set()) => {
114
115
  export function flowView(store, flow, viewer, selectedCard, setup = { dir: null, repos: [] }) {
115
116
  const definition = flowDefinitionOf(flow);
116
117
  const stages = definition?.stages ?? [];
118
+ const browserNow = browserViewOf(store, flow);
119
+ const browser = browserNow === null ? null : { ...browserNow, canApprove: viewer.approver && store.accountOf(viewer.name)?.projects === null };
117
120
  const title = (id) => stages.find(one => one.id === id)?.title ?? id;
118
121
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
119
122
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
@@ -179,10 +182,15 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
179
182
  // After research: the same numbered items the message carried, each beside its screenshot.
180
183
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
181
184
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
185
+ const browserWait = card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
186
+ && card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
182
187
  return {
183
188
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
184
189
  waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
185
- ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
190
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable"
191
+ // Waiting for browser use: the page offers Allow browser itself, so the command line the CLI shows is left off.
192
+ : browserWait ? card.waiting.replace(/ Approve it with: toolroll flows browser \d+ on$/, "") : card.waiting,
193
+ ...(browserWait ? { browserWait: true } : {}),
186
194
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}`,
187
195
  // Its running attempt's composer, for an approver who may read the task (steer-controls.ts).
188
196
  ...(() => {
@@ -262,6 +270,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
262
270
  live: flowFingerprint(store, flow.id),
263
271
  canEdit: viewer.approver,
264
272
  unattended: unattendedViewOf(store, flow, viewer, setup.now ?? now),
273
+ browser,
265
274
  approvers: store.listApprovers().map(one => one.name).filter(name => store.accountCanAccess(name, flow.repo)),
266
275
  projects: [flow.repo, ...setup.repos.filter(one => one !== flow.repo)].map(path => ({ path, name: projectName(path) })),
267
276
  kinds: FLOW_STAGE_KINDS.map(kind => ({ kind, label: FLOW_KIND_WORDS[kind].label, about: FLOW_KIND_WORDS[kind].about })),
@@ -271,7 +280,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
271
280
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
272
281
  export function flowFallbackHtml(view) {
273
282
  const own = view.unattended.active;
274
- return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}<ol>${view.stages.map(stage => {
283
+ return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}${view.browser !== null && html `<p data-flow-browser="${view.browser.status}">${view.browser.words}</p>`}<ol>${view.stages.map(stage => {
275
284
  const cards = view.cards.filter(card => card.stage === stage.id && card.state === "active");
276
285
  return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
277
286
  })}</ol></section>`;
package/dist/flows.js CHANGED
@@ -224,7 +224,9 @@ function canonicalStage(input, index) {
224
224
  close: kind === "update" ? stage["close"] !== false : null,
225
225
  script: kind === "check" ? said(stage["script"]) : null,
226
226
  // Left out, a script runs where every script ran before v90: in a copy of the card's work.
227
- ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
227
+ ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }),
228
+ // v144: a script that starts a browser says so; false or none is a script that doesn't (and keeps its digest).
229
+ ...(!given(stage["browser"]) || stage["browser"] === false ? {} : { browser: stage["browser"] }) } : {}),
228
230
  sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
229
231
  ...(kind === "request" ? { request: { method, url: said(request["url"]), headers: Object.fromEntries(Object.entries(isRecord(request["headers"]) ? request["headers"] : {}).flatMap(([name, value]) => { const one = said(value); return one === null ? [] : [[name, one]]; })), body: method === "GET" || method === "DELETE" ? null : said(request["body"]) } } : {}),
230
232
  ...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
@@ -732,6 +734,7 @@ export function flowFromSteps(input, previous = null) {
732
734
  ...(step.runIn !== undefined ? { runIn: step.runIn } : old?.runIn === undefined ? {} : { runIn: old.runIn }),
733
735
  ...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
734
736
  ...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
737
+ ...((step.browser ?? old?.browser) === true ? { browser: true } : {}),
735
738
  } : {}),
736
739
  sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
737
740
  ...(kind === "request" ? { request: { method: step.method ?? old?.request?.method ?? "POST", url: step.url ?? old?.request?.url ?? "", headers: step.headers ?? old?.request?.headers ?? {}, body: step.body ?? old?.request?.body ?? null } } : {}),
@@ -912,7 +915,7 @@ export function flowTerms(definition, previous) {
912
915
  lines.push("Opens a pull request for the card's built result, then waits for CI.", `Checks pass → ${stage.merge === undefined ? to(stage.next) : `merges it (${stage.merge}) and deletes its branch, only when a person approved it${approval === undefined ? "" : ` at ${approval.title}`} since it was built. Then → ${to(stage.next)}`}`, `Checks fail → ${stage.onFail === null ? "waits there, naming the failing check." : `${to(stage.onFail)} The failing check is named on the card.`}`);
913
916
  }
914
917
  else if (stage.kind === "check")
915
- lines.push(`Runs the project's script “${stage.script}” with no AI.`, `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
918
+ lines.push(`Runs the project's script “${stage.script}” with no AI.`, ...(stage.browser === true ? ["Starts a browser (Playwright's Chromium): it runs only once a person approves browser use for this flow."] : []), `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
916
919
  else if (stage.kind === "wait" && stage.wait !== undefined)
917
920
  lines.push(stage.wait.for === "hours" ? `Waits until it's between ${stage.wait.from} and ${stage.wait.to}${stage.wait.timeZone === undefined ? "" : ` (${stage.wait.timeZone})`}; cards that arrive then go straight on. Then → ${to(stage.next)}`
918
921
  : stage.wait.for === "time" ? `Waits ${durationWords(stage.wait.minutes)}. Then → ${to(stage.next)}`
@@ -14,7 +14,7 @@ export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.inte
14
14
  `.integrations .integration>:not(.integration-action),.integrations .integration-body>*{grid-column:1}.integrations .integration-body{display:contents}.integrations .integration:first-of-type{border-top:0}.integrations .integration-head{margin:0;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}` +
15
15
  `.integrations .integration .integration-body>*{margin-left:calc(var(--brand-mark) + 10px)}.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
16
16
  `.integrations .integration-action{grid-column:2;grid-row:1 / span 3;align-self:center;margin:0}.integrations .integration-action button,.integrations .integration-action .button-link{min-height:44px;white-space:nowrap}` +
17
- `.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
17
+ `.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet,.integrations .integration .integration-action button.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
18
18
  `.integrations .integration-fix{color:var(--so-danger)}.integrations code{overflow-wrap:anywhere}.integrations details{margin-top:4px;font-size:.8125rem}.integrations summary{cursor:pointer;padding:4px 0}` +
19
19
  `.integration-state{display:inline-flex;align-items:center;gap:6px;font-size:.75rem;font-weight:500;padding:0 8px;border-radius:5px;line-height:20px;white-space:nowrap}` +
20
20
  `.integration-state i{width:6px;height:6px;border-radius:50%;background:currentColor}` +
@@ -33,6 +33,10 @@ function action(one, csrf) {
33
33
  if (a.kind === "test") {
34
34
  return csrf === "" ? "" : postForm("/settings/integrations/test", html `<button type="submit">Send test</button>`, { attrs: { class: "integration-action" }, hidden: { key: one.key } });
35
35
  }
36
+ // A project's youiyoux requests: one switch for that project.
37
+ if (a.kind === "switch") {
38
+ return csrf === "" ? "" : postForm("/settings/integrations/youiyoux", html `<button type="submit"${a.on ? "" : html ` class="integration-quiet"`}>${a.label}</button>`, { attrs: { class: "integration-action" }, hidden: { repo: a.repo, on: a.on ? "1" : "0" } });
39
+ }
36
40
  // A command is run where Toolroll runs; a page is linked. Fix and Set up both say which.
37
41
  // Fix is the one filled action on the page; Set up stays quiet so a list of unused services doesn't shout.
38
42
  if (a.href !== null)
@@ -50,12 +54,13 @@ function row(one, csrf) {
50
54
  ? html `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
51
55
  : html `<span class="integration-state integration-state--${one.state}"><i aria-hidden="true"></i>${STATE_WORDS[one.state]}</span>`;
52
56
  const facts = [one.account, one.detail, one.credentials].filter(Boolean).join(" · ");
53
- const command = one.action.kind !== "test" ? one.action.command : null;
54
- const fix = one.action.kind === "fix" ? html `<p class="integration-fix" role="status">${one.action.words}</p>` : "";
57
+ const command = one.action.kind === "setup" || one.action.kind === "fix" ? one.action.command : null;
58
+ const words = one.action.kind === "fix" || one.action.kind === "switch" ? one.action.words : null;
59
+ const fix = words !== null ? html `<p class="integration-fix" role="status">${words}</p>` : "";
55
60
  const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : html `<p class="meta">Run <code>${command}</code> on the computer running Toolroll.</p>`;
56
61
  const seen = [one.lastSuccessAt === null ? null : html `Last success ${when(one.lastSuccessAt)}`, one.usedBy.length === 0 ? null : html `Used by ${one.usedBy.join(", ")}`].filter(Boolean);
57
62
  const history = one.state === "not-set-up" || seen.length === 0 ? "" : html `<p class="meta">${joinHtml(seen, " · ")}</p>`;
58
- const lastError = one.lastError === null || one.state === "not-set-up" ? "" : one.state === "broken" && one.action.kind === "fix" && one.action.words === one.lastError
63
+ const lastError = one.lastError === null || one.state === "not-set-up" ? "" : one.state === "broken" && words === one.lastError
59
64
  ? html `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
60
65
  : html `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${one.lastError}</p></details>`;
61
66
  return html `<div class="integration" data-integration="${one.key}" data-state="${one.state}"><p class="integration-head">${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<strong>${one.name}</strong> ${badge}</p>${action(one, csrf)}<div class="integration-body">${facts === "" ? "" : html `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
@@ -18,7 +18,15 @@ export type IntegrationAction = ({
18
18
  kind: "fix";
19
19
  label: "Fix";
20
20
  words: string;
21
- } & IntegrationPlace);
21
+ } & IntegrationPlace)
22
+ /** A project's youiyoux requests: one switch, and the problem to fix when it is Broken. */
23
+ | {
24
+ kind: "switch";
25
+ label: "Turn off" | "Turn on";
26
+ repo: string;
27
+ on: boolean;
28
+ words: string | null;
29
+ };
22
30
  export type Integration = {
23
31
  key: string;
24
32
  group: IntegrationGroup;
@@ -31,6 +31,7 @@ import { LINEAR_URL, readHooksBase, readLinearKey } from "./flow-triggers.js";
31
31
  import { credentialModeOf, projectToolsOf, readToolSecrets, missingSecrets, secretsSetFor, testToolOf } from "./project-tools.js";
32
32
  import { origin, readMonitoring } from "./monitoring-settings.js";
33
33
  import { targetOf } from "./monitoring.js";
34
+ import { youiyouxState } from "./youiyoux-intake.js";
34
35
  export const STATE_WORDS = { connected: "Connected", "not-set-up": "Not set up", broken: "Broken" };
35
36
  const NO_ACTIVITY = { okAt: null, error: null, errorAt: null, failing: false };
36
37
  const PROBE_MS = 8_000;
@@ -459,10 +460,46 @@ function combine(target, row) {
459
460
  }
460
461
  return { ...base, ...common, state: "connected", action: { kind: "test", label: "Send test" } };
461
462
  }
463
+ /**
464
+ * youiyoux change requests, one row per project that uses youiyoux (youiyoux-intake.ts): Connected with how many of
465
+ * its requests are queued, Broken when the last pass couldn't read its folder or some of its files, or Not set up when
466
+ * a person turned it off; one row Not set up when no project uses it. Read from the store and the folders' presence.
467
+ */
468
+ function youiyouxIntegrations(io) {
469
+ // In the order every other row's keys come (combine), so `--json` reads alike.
470
+ const row = (key, account, facts, state, action) => ({
471
+ key, group: "code", name: "youiyoux", usedBy: facts.usedBy ?? [], account, detail: facts.detail, checked: true, checkedAt: facts.checkedAt ?? null,
472
+ lastSuccessAt: facts.lastSuccessAt ?? null, lastError: facts.lastError ?? null, lastErrorAt: facts.lastErrorAt ?? null, state, action,
473
+ });
474
+ const rows = io.repos.flatMap((repo) => {
475
+ let state;
476
+ try {
477
+ state = youiyouxState(io.store, repo);
478
+ }
479
+ catch {
480
+ return [];
481
+ }
482
+ if (!state.uses)
483
+ return [];
484
+ const key = `youiyoux:${repo}`, account = basename(repo);
485
+ const turn = (on, words) => ({ kind: "switch", label: on ? "Turn on" : "Turn off", repo, on, words });
486
+ if (state.off)
487
+ return [row(key, account, { detail: "Turned off" }, "not-set-up", turn(true, null))];
488
+ const facts = { usedBy: [`Tasks in ${account}`], detail: `${plural(state.queued, "request")} queued`, checkedAt: state.checkedAt };
489
+ if (state.problem === null)
490
+ return [row(key, account, { ...facts, lastSuccessAt: state.checkedAt }, "connected", turn(false, null))];
491
+ const words = scrubIntegrationText(state.problem);
492
+ return [row(key, account, { ...facts, lastError: words, lastErrorAt: state.checkedAt }, "broken", turn(false, words))];
493
+ });
494
+ return rows.length > 0 ? rows : [row("youiyoux", null, { detail: "Starts by itself in a project with a youi.project.json" }, "not-set-up", { kind: "setup", label: "Set up", href: null, command: null })];
495
+ }
462
496
  /** Every integration as it stands now, from files and saved history only: no network, no process, never slow. */
463
497
  export function integrationsNow(io) {
464
498
  const rows = readIntegrationChecks(io.store);
465
- return targets(io).map(target => combine(target, rows.get(target.key)));
499
+ const list = targets(io).map(target => combine(target, rows.get(target.key)));
500
+ const linear = list.findIndex(one => one.key === "linear");
501
+ list.splice(linear + 1, 0, ...youiyouxIntegrations(io));
502
+ return list;
466
503
  }
467
504
  /** Run the checks (all, or the named ones), record each answer, and return the list. */
468
505
  export async function checkIntegrations(io, keys) {
@@ -512,7 +549,11 @@ export function renderIntegrations(list, secrets = null) {
512
549
  lines.push(`${"".padEnd(width)} last success ${whenText(one.lastSuccessAt)}${one.lastError === null ? "" : ` · last error ${whenText(one.lastErrorAt)}: ${one.lastError}`}`);
513
550
  if (one.action.kind === "fix")
514
551
  lines.push(`${"".padEnd(width)} Fix: ${one.action.words}${one.action.command === null || one.action.words.includes(one.action.command) ? "" : ` (run ${one.action.command})`}`);
515
- if (one.action.kind === "setup")
552
+ if (one.action.kind === "switch" && one.action.words !== null)
553
+ lines.push(`${"".padEnd(width)} Fix: ${one.action.words}`);
554
+ if (one.action.kind === "switch" && one.action.on)
555
+ lines.push(`${"".padEnd(width)} Turn on: Settings → Integrations`);
556
+ if (one.action.kind === "setup" && (one.action.command !== null || one.action.href !== null))
516
557
  lines.push(`${"".padEnd(width)} Set up: ${one.action.command !== null ? `run ${one.action.command}` : `Settings (${one.action.href ?? "/settings"})`}`);
517
558
  return lines;
518
559
  }), ...note];
@@ -1,3 +1,4 @@
1
+ import { type AssignmentSnapshot } from "./assignment.js";
1
2
  import type { RemoteMainRead, RemoteMains } from "./remote-main.js";
2
3
  import type { Store } from "./store.js";
3
4
  import { type Git } from "./teardown-git.js";
@@ -70,6 +71,9 @@ export type LandedCandidate = {
70
71
  targets: LandedTarget[];
71
72
  keeps: LandedKeep[];
72
73
  };
74
+ /** Use the current manual completion guards. Only a recorded merge of this exact result can settle them. Also why a
75
+ * youiyoux options task a person decided on in the hub stays for review (youiyoux-intake.ts). */
76
+ export declare function completionProblem(store: Store, assignment: AssignmentSnapshot, root: string): string | null;
73
77
  /**
74
78
  * Results in a project whose work landed, with what their teardown would remove and what stays. Reads only.
75
79
  * `limit` caps how many results are read against git (reconcile's bound; results read before and unchanged since
@@ -237,8 +237,9 @@ function resultProblem(store, runId, head) {
237
237
  return "its result couldn't be read";
238
238
  return run.outcome === "no-change" || run.baseRevision === head ? "its result has no new work" : null;
239
239
  }
240
- /** Use the current manual completion guards. Only a recorded merge of this exact result can settle them. */
241
- function completionProblem(store, assignment, root) {
240
+ /** Use the current manual completion guards. Only a recorded merge of this exact result can settle them. Also why a
241
+ * youiyoux options task a person decided on in the hub stays for review (youiyoux-intake.ts). */
242
+ export function completionProblem(store, assignment, root) {
242
243
  if (assignment.state !== "ready-to-check" && assignment.state !== "complete")
243
244
  return "it needs a decision first";
244
245
  if (assignment.state === "complete")
@@ -181,7 +181,8 @@ export function integrationCapabilities(list) {
181
181
  return list.map(one => {
182
182
  const link = one.group === "agents" ? "providers" : one.group === "tools" ? "tools" : INTEGRATION_LINK[one.key.replace(/^.*:/, "").toLowerCase()] ?? INTEGRATION_LINK[one.name.toLowerCase()] ?? "integrations";
183
183
  const lets = one.usedBy.length > 0 ? `use ${one.name} for ${one.usedBy.join(", ")}` : `use ${one.name}`;
184
- const next = one.action.kind === "setup" ? `Not set up: set up ${one.name} in Settings` : one.action.kind === "fix" ? `Fix: ${one.action.words}` : null;
184
+ const next = one.action.kind === "setup" ? `Not set up: set up ${one.name} in Settings` : one.action.kind === "fix" ? `Fix: ${one.action.words}`
185
+ : one.action.kind === "switch" ? (one.action.words !== null ? `Fix: ${one.action.words}` : one.action.on ? `Turned off: turn ${one.name} on in Settings → Integrations` : null) : null;
185
186
  return { name: one.name, kind: one.group, state: STATE_WORDS[one.state], ok: one.state === "connected", account: one.account, usedBy: one.usedBy, lastError: one.lastError, lets, next, link: next === null ? null : link };
186
187
  });
187
188
  }
package/dist/lead-cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseFeedbackImage } from "./result-review.js";
1
2
  import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
2
3
  import { closedQuestionWords } from "./decision.js";
3
4
  /**
@@ -104,7 +105,8 @@ export function proposalBlock(store, proposal, repos, number) {
104
105
  const snapshot = proposal.payload["snapshot"];
105
106
  const selected = Array.isArray(proposal.payload["notes"]) ? proposal.payload["notes"] : [];
106
107
  const notes = snapshot?.notes.filter(one => selected.includes(one.id)) ?? [];
107
- return [line, ...notes.map(one => ` ${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`}: `}${one.note}`),
108
+ const image = parseFeedbackImage(proposal.payload["image"]);
109
+ return [line, ...(image ? [` Screenshot ${image.artifact} · result #${image.run}`] : []), ...notes.map(one => ` ${one.image ? `Screenshot ${one.image.artifact} · result #${one.image.run}: ` : ""}${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`}: `}${one.note}`),
108
110
  proposal.payload["operation"] === "revise" ? " Creates a revision of the same task; approval is separate." : " Saves feedback; no work starts."];
109
111
  }
110
112
  return proposal.kind === "answer" ? [line, ...answerContextLines(store, proposal.payload)] : [line];
@@ -1,3 +1,4 @@
1
+ import { type FeedbackImage } from "./result-review.js";
1
2
  import { type CommitmentChannel } from "./lead-commitments.js";
2
3
  import { type Integration } from "./integrations.js";
3
4
  import type { AuthMode } from "./keys.js";
@@ -23,6 +24,7 @@ export type LeadToolContext = {
23
24
  step: number;
24
25
  snapshot: ReviewSnapshot;
25
26
  }>;
27
+ replyImage?: FeedbackImage;
26
28
  /** Where evidence lives, when the surface knows — a scout's report reads from here. */
27
29
  evidenceRoot?: string;
28
30
  /** Records the screenshots this turn selected, under the turn, for a channel that delivers files; returns how many are recorded. Absent: nothing durable is kept. */