toolroll 0.9.73 → 0.9.75
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.
- package/CHANGELOG.md +63 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-turns.js +5 -0
- package/dist/agents-live.d.ts +31 -0
- package/dist/agents-live.js +43 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +27 -8
- package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -30
- package/dist/browser-workspace.d.ts +7 -1
- package/dist/builder.js +1 -1
- package/dist/chat-acceptance.d.ts +4 -1
- package/dist/chat-acceptance.js +13 -3
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-quiet.js +3 -3
- package/dist/cli.js +1 -1
- package/dist/coding-handoff.js +4 -3
- package/dist/coding-ui.js +2 -2
- package/dist/coding-workspace.js +16 -4
- package/dist/contracts/console-api.d.ts +5 -1
- package/dist/contracts/console-api.js +10 -0
- package/dist/control-ui.d.ts +3 -2
- package/dist/control-ui.js +7 -2
- package/dist/demo.js +5 -3
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +4 -1
- package/dist/desktop-update.js +43 -9
- package/dist/dispose.js +35 -2
- package/dist/exec.js +13 -16
- package/dist/first-run.d.ts +20 -1
- package/dist/first-run.js +71 -0
- package/dist/flow-card-task.js +3 -2
- package/dist/flow-unattended.d.ts +2 -0
- package/dist/flow-unattended.js +12 -1
- package/dist/integrations.d.ts +4 -3
- package/dist/integrations.js +5 -6
- package/dist/invoke.d.ts +13 -0
- package/dist/invoke.js +21 -12
- package/dist/knowledge-ui.d.ts +4 -1
- package/dist/knowledge-ui.js +10 -5
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-follow.js +3 -1
- package/dist/lead-progress.d.ts +4 -1
- package/dist/lead-progress.js +3 -1
- package/dist/lead.js +4 -0
- package/dist/live-bus.d.ts +2 -0
- package/dist/live-bus.js +1 -0
- package/dist/live-children-read.d.ts +52 -0
- package/dist/live-children-read.js +333 -0
- package/dist/live-children.d.ts +73 -0
- package/dist/live-children.js +318 -0
- package/dist/live-detail.d.ts +4 -3
- package/dist/live-detail.js +9 -5
- package/dist/live-input.js +7 -8
- package/dist/live-main-read.d.ts +19 -0
- package/dist/live-main-read.js +91 -0
- package/dist/live-open.d.ts +28 -0
- package/dist/live-open.js +125 -0
- package/dist/live-records.d.ts +10 -1
- package/dist/live-records.js +16 -5
- package/dist/live.d.ts +54 -22
- package/dist/live.js +218 -69
- package/dist/openrouter-models.d.ts +5 -2
- package/dist/openrouter-models.js +7 -4
- package/dist/operate.js +5 -2
- package/dist/peek-cli.d.ts +10 -3
- package/dist/peek-cli.js +17 -8
- package/dist/planner.js +1 -1
- package/dist/project-learning.js +44 -6
- package/dist/project-tools.js +19 -1
- package/dist/provider-limits.d.ts +3 -1
- package/dist/provider-limits.js +5 -2
- package/dist/provider.js +6 -12
- package/dist/pull-request-ui.d.ts +1 -1
- package/dist/pull-request-ui.js +5 -1
- package/dist/push.d.ts +2 -0
- package/dist/push.js +24 -19
- package/dist/read-only-request.d.ts +4 -0
- package/dist/read-only-request.js +15 -0
- package/dist/release-adapters.js +4 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +3 -0
- package/dist/repository-context-ui.js +2 -1
- package/dist/repository-context.js +6 -1
- package/dist/result-follow-ups.js +20 -1
- package/dist/result-review.js +5 -1
- package/dist/run-efficiency.d.ts +3 -3
- package/dist/run-efficiency.js +20 -8
- package/dist/scout.d.ts +3 -0
- package/dist/scout.js +5 -3
- package/dist/serve.js +196 -29
- package/dist/server/agents-live-filed.d.ts +17 -0
- package/dist/server/agents-live-filed.js +97 -0
- package/dist/server/agents-live-lead.d.ts +47 -0
- package/dist/server/agents-live-lead.js +116 -0
- package/dist/server/agents-live-room.d.ts +23 -0
- package/dist/server/agents-live-room.js +91 -0
- package/dist/server/chat.js +115 -53
- package/dist/server/chrome.js +2 -2
- package/dist/server/console.css +68 -40
- package/dist/server/http.d.ts +5 -0
- package/dist/server/last-checks.d.ts +17 -0
- package/dist/server/last-checks.js +22 -0
- package/dist/server/live.d.ts +4 -0
- package/dist/server/live.js +42 -2
- package/dist/server/pages.js +98 -4
- package/dist/server/people-tokens.js +7 -4
- package/dist/server/render-chat.d.ts +15 -1
- package/dist/server/render-chat.js +14 -7
- package/dist/server/render-pages.d.ts +10 -5
- package/dist/server/render-pages.js +83 -74
- package/dist/server/render-results.d.ts +11 -2
- package/dist/server/render-results.js +54 -12
- package/dist/server/render-settings.js +3 -3
- package/dist/server/render-tasks.js +13 -3
- package/dist/server/route-table.js +19 -2
- package/dist/server/runtime.d.ts +17 -2
- package/dist/server/settings.js +85 -32
- package/dist/server/task-views.js +1 -2
- package/dist/server/tasks.js +70 -43
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +1 -1
- package/dist/storage.js +2 -1
- package/dist/store.d.ts +34 -1
- package/dist/store.js +108 -11
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subagent-work.js +3 -0
- package/dist/subscription-chat.js +5 -3
- package/dist/task-status.d.ts +28 -1
- package/dist/task-status.js +93 -11
- package/dist/teardown-git.d.ts +27 -1
- package/dist/teardown-git.js +185 -39
- package/dist/telegram-progress.js +4 -4
- package/dist/telegram-status.js +8 -3
- package/dist/tool-secret-store.js +8 -0
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +4 -3
- package/dist/toolroll-update.js +10 -9
- package/dist/work-index.js +29 -12
- package/dist/workspace-ui.d.ts +5 -0
- package/dist/workspace-ui.js +38 -10
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { capLive, cleanLiveText, HIDDEN_KEY_BLOCK, HIDDEN_LINE, liveLine, redactLiveLines } from "../live-records.js";
|
|
2
|
+
import { projectChatHref, taskChatHref } from "./http.js";
|
|
3
|
+
/** A lead tile keeps at most this many lines, and this many bytes of them (the newest). */
|
|
4
|
+
export const LEAD_LINES = 200, LEAD_BYTES = 32 * 1024;
|
|
5
|
+
/** A tool's line: fixed copy from lead-progress.ts (labels and failure reasons), still cleaned and cut. */
|
|
6
|
+
function toolLine(tool) {
|
|
7
|
+
return liveLine(tool.state === "running" ? `· ${tool.label}…` : tool.state === "failed" ? `· ${tool.reason}` : `· ${tool.label}`);
|
|
8
|
+
}
|
|
9
|
+
/** One step's words, redacted, with only what is settled: the last word of the step's text waits for the space after
|
|
10
|
+
* it unless the provider's finished answer for that step was confirmed (lead.ts sends it once, secret-checked whole).
|
|
11
|
+
* A snapshot that would have completed a credential is never stored (lead.ts drops it), so the one kept can end
|
|
12
|
+
* part-way through one: ending the turn or starting the next step confirms nothing. The last line is redacted on its
|
|
13
|
+
* own (after the rest, with the same key state), the whole line checked for secrets first, and its unfinished word
|
|
14
|
+
* found in the line as shown (invisible characters removed), so no hidden character can stand in for the space. */
|
|
15
|
+
function stepLines(text, keys, confirmed) {
|
|
16
|
+
const raw = text.split(/\r?\n/);
|
|
17
|
+
const lastRaw = raw.pop() ?? "";
|
|
18
|
+
const out = redactLiveLines(raw, keys);
|
|
19
|
+
// Empty when the last line is inside a key block (never shown).
|
|
20
|
+
for (const shown of redactLiveLines([lastRaw], keys)) {
|
|
21
|
+
const whole = shown === HIDDEN_LINE || shown === HIDDEN_KEY_BLOCK || confirmed || /\s$/.test(cleanLiveText(lastRaw));
|
|
22
|
+
out.push((whole ? shown : shown.replace(/\S+$/, "")).trimEnd());
|
|
23
|
+
}
|
|
24
|
+
while (out.length > 0 && out[out.length - 1].trim() === "")
|
|
25
|
+
out.pop();
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** One turn's lines, redacted as one stream: every step's tools and words in order through ONE key state (a key block
|
|
29
|
+
* opened in one step stays hidden into the next), each line cleaned and cut, then the newest kept within the caps.
|
|
30
|
+
* `confirmed[i]`: step i's text is its finished answer, secret-checked whole. */
|
|
31
|
+
export function leadLines(steps, confirmed = []) {
|
|
32
|
+
const keys = { inKey: false };
|
|
33
|
+
const all = [];
|
|
34
|
+
steps.forEach((step, index) => {
|
|
35
|
+
// Tools are not the conversation's text: they never open or close a key block, and inside one they are not shown.
|
|
36
|
+
for (const tool of step.toolCalls)
|
|
37
|
+
if (!keys.inKey)
|
|
38
|
+
all.push(toolLine(tool));
|
|
39
|
+
if (step.text !== "")
|
|
40
|
+
all.push(...stepLines(step.text, keys, confirmed[index] === true));
|
|
41
|
+
});
|
|
42
|
+
const kept = [];
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
for (let index = all.length - 1; index >= 0 && kept.length < LEAD_LINES; index -= 1) {
|
|
45
|
+
const one = capLive(all[index], LEAD_BYTES);
|
|
46
|
+
const size = Buffer.byteLength(one, "utf8") + 1;
|
|
47
|
+
if (bytes + size > LEAD_BYTES)
|
|
48
|
+
break;
|
|
49
|
+
kept.unshift(one);
|
|
50
|
+
bytes += size;
|
|
51
|
+
}
|
|
52
|
+
return { lines: kept, earlier: kept.length < all.length };
|
|
53
|
+
}
|
|
54
|
+
/** What the turn is doing now, in fixed words only. */
|
|
55
|
+
function doingOf(turn) {
|
|
56
|
+
if (turn.done)
|
|
57
|
+
return null;
|
|
58
|
+
const step = turn.steps.at(-1);
|
|
59
|
+
const running = step?.toolCalls.filter(tool => tool.state === "running").at(-1);
|
|
60
|
+
if (running !== undefined)
|
|
61
|
+
return running.label;
|
|
62
|
+
return step !== undefined && step.text.trim() !== "" ? "Writing a reply" : "Thinking";
|
|
63
|
+
}
|
|
64
|
+
/** One thread admitted for this viewer now, as the chat room admits (server/live.ts, room "chat"); null when refused. */
|
|
65
|
+
function admitLead(deps, who, id) {
|
|
66
|
+
const { store, leadPrincipal, familyOf, visible, managedRepos } = deps;
|
|
67
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
68
|
+
return null;
|
|
69
|
+
const principal = leadPrincipal(who);
|
|
70
|
+
if (principal === null)
|
|
71
|
+
return null;
|
|
72
|
+
const thread = store.getLeadThread(id);
|
|
73
|
+
if (thread === null || thread.approver !== who.name || thread.closedAt !== null)
|
|
74
|
+
return null;
|
|
75
|
+
// The owner's current thread for its scope (never a team conversation's), under the ceiling it opened with.
|
|
76
|
+
if (store.liveLeadThreadFor(who.name, thread.scope)?.id !== id || thread.ceilingDigest !== principal.ceilingDigest)
|
|
77
|
+
return null;
|
|
78
|
+
if (thread.scope.kind === "project") {
|
|
79
|
+
const repo = thread.scope.key;
|
|
80
|
+
if (!visible(repo) || !managedRepos().includes(repo) || !store.accountCanAccess(who.name, repo))
|
|
81
|
+
return null;
|
|
82
|
+
return { scope: "project", title: "Project chat", project: repo, chatHref: projectChatHref(repo), taskHref: null };
|
|
83
|
+
}
|
|
84
|
+
if (thread.scope.kind === "task") {
|
|
85
|
+
const family = familyOf(thread.scope.key);
|
|
86
|
+
const repo = family?.root.repo ?? null;
|
|
87
|
+
if (family === null || family.problem !== null || family.root.id !== thread.scope.key || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
|
|
88
|
+
return null;
|
|
89
|
+
return { scope: "task", title: family.root.title, project: repo, chatHref: taskChatHref(family.root.id), taskHref: `/t/${encodeURIComponent(family.root.id)}` };
|
|
90
|
+
}
|
|
91
|
+
return { scope: "lead", title: "Lead chat", project: null, chatHref: "/chat", taskHref: null };
|
|
92
|
+
}
|
|
93
|
+
/** The viewer's own live lead turns, admitted as the chat room admits them, proved now. */
|
|
94
|
+
export function leadTilesFor(deps, who) {
|
|
95
|
+
const tiles = [];
|
|
96
|
+
for (const [id, turn] of deps.liveTurns) {
|
|
97
|
+
const admitted = admitLead(deps, who, id);
|
|
98
|
+
if (admitted === null)
|
|
99
|
+
continue;
|
|
100
|
+
const { lines, earlier } = leadLines(turn.steps, turn.confirmed);
|
|
101
|
+
tiles.push({
|
|
102
|
+
thread: id, turn: turn.turn ?? null, ...admitted,
|
|
103
|
+
state: !turn.done ? "working" : turn.ok ? "done" : "failed",
|
|
104
|
+
startedAt: new Date(turn.startedAt ?? Date.now()).toISOString(),
|
|
105
|
+
endedAt: turn.endedAt === undefined ? null : new Date(turn.endedAt).toISOString(),
|
|
106
|
+
stepAt: (turn.stepAt ?? []).map(at => new Date(at).toISOString()),
|
|
107
|
+
doing: doingOf(turn), lines, earlier,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return tiles.sort((a, b) => a.thread - b.thread);
|
|
111
|
+
}
|
|
112
|
+
/** The tiles projected earlier that are still this viewer's right now (the whole admission proved again, with the
|
|
113
|
+
* session as it is saved now): what is delivered is never more than what is admitted at the moment of delivery. */
|
|
114
|
+
export function leadsStillAdmitted(deps, who, tiles) {
|
|
115
|
+
return tiles.filter(tile => deps.liveTurns.has(tile.thread) && admitLead(deps, who, tile.thread) !== null);
|
|
116
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type LiveBus, type LiveViewer } from "../live-bus.js";
|
|
2
|
+
import type { Store } from "../store.js";
|
|
3
|
+
/** How often a viewer's runs and files are looked at again. */
|
|
4
|
+
export declare const LIVE_POLL_MS = 2000;
|
|
5
|
+
/** At most this many of a viewer's runs have their files looked at (the grid streams at most this many). */
|
|
6
|
+
export declare const LIVE_WATCHED_RUNS = 24;
|
|
7
|
+
export declare function createAgentsLiveRooms<V extends LiveViewer>(input: {
|
|
8
|
+
store: Store;
|
|
9
|
+
bus: LiveBus;
|
|
10
|
+
evidenceRoot: string;
|
|
11
|
+
localRunner: string | undefined;
|
|
12
|
+
clock: () => Date;
|
|
13
|
+
/** Whether the console shows this project at all (its ceiling). */
|
|
14
|
+
visible: (repo: string | null) => boolean;
|
|
15
|
+
/** The keyed viewer's own lead tiles as they stand now (agents-live-lead.ts), proved from their saved session. */
|
|
16
|
+
leads?: (key: string) => unknown;
|
|
17
|
+
/** Told when a page's room is gone. */
|
|
18
|
+
forget?: (key: string) => void;
|
|
19
|
+
}): {
|
|
20
|
+
rooms: import("../live-bus.js").LiveRooms<string, V>;
|
|
21
|
+
keyOf: (account: string, scope: string, page?: string) => string;
|
|
22
|
+
looked: () => number;
|
|
23
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Live grid's room (docs/plans/agents-view.md §3): one room per viewer and scope, never shared, so one viewer's
|
|
3
|
+
* view is never cached for another. Every LIVE_POLL_MS it re-proves each viewer (before any read and before any send,
|
|
4
|
+
* keep-alives included — createLiveRooms' own boundary), then takes the viewer's fingerprint: the tiles they may see
|
|
5
|
+
* and, for this computer's runs, each live file's identity and size through the checked opener. The page hears only
|
|
6
|
+
* that something it can see moved; the fingerprint is an opaque digest, never sizes or file identities. Expiry needs
|
|
7
|
+
* no database write (the tiles are derived from the clock), and the room goes with its last viewer.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { closeSync } from "node:fs";
|
|
11
|
+
import { agentsLiveOf } from "../agents-live.js";
|
|
12
|
+
import { createLiveRooms } from "../live-bus.js";
|
|
13
|
+
import { openLiveTrusted } from "../live-open.js";
|
|
14
|
+
import { leadFiledLinks } from "./agents-live-filed.js";
|
|
15
|
+
import { requestContext } from "./request-context.js";
|
|
16
|
+
/** How often a viewer's runs and files are looked at again. */
|
|
17
|
+
export const LIVE_POLL_MS = 2_000;
|
|
18
|
+
/** At most this many of a viewer's runs have their files looked at (the grid streams at most this many). */
|
|
19
|
+
export const LIVE_WATCHED_RUNS = 24;
|
|
20
|
+
/** One file as the room sees it: its identity and size, or that it is missing or unavailable. Never its contents. */
|
|
21
|
+
function fileMark(root, name) {
|
|
22
|
+
const opened = openLiveTrusted(root, name);
|
|
23
|
+
if (typeof opened === "string")
|
|
24
|
+
return opened;
|
|
25
|
+
try {
|
|
26
|
+
return `${opened.gen}/${opened.size}`;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
closeSync(opened.fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function createAgentsLiveRooms(input) {
|
|
33
|
+
const { store, evidenceRoot, localRunner, clock, visible } = input;
|
|
34
|
+
const keyOf = (account, scope, page = "") => JSON.stringify([account, scope, page]);
|
|
35
|
+
/** One look at a run's two files, shared by every viewer's room: at most once per LIVE_POLL_MS per run, however
|
|
36
|
+
* many viewers or bus signals there are (a burst of writes re-proves at once, but never opens files more often). */
|
|
37
|
+
const looked = new Map();
|
|
38
|
+
const marksOf = (runId, stillSees) => {
|
|
39
|
+
// A run's files are this viewer's to look at (or have looked at) only while the run is still theirs and this
|
|
40
|
+
// computer's, proved from its row now — a look another viewer's room took included.
|
|
41
|
+
if (!stillSees())
|
|
42
|
+
return null;
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const kept = looked.get(runId);
|
|
45
|
+
// One attempt per run per poll, whether it looked or stopped part-way: nothing makes files opened more often.
|
|
46
|
+
if (kept !== undefined && now - kept.at < LIVE_POLL_MS)
|
|
47
|
+
return kept.marks;
|
|
48
|
+
looked.set(runId, { at: now, marks: null });
|
|
49
|
+
const main = fileMark(evidenceRoot, `${runId}.jsonl`);
|
|
50
|
+
if (!stillSees())
|
|
51
|
+
return null;
|
|
52
|
+
const sub = fileMark(evidenceRoot, `${runId}.sub.jsonl`);
|
|
53
|
+
if (!stillSees())
|
|
54
|
+
return null;
|
|
55
|
+
const marks = [main, sub];
|
|
56
|
+
looked.set(runId, { at: now, marks });
|
|
57
|
+
return marks;
|
|
58
|
+
};
|
|
59
|
+
const rooms = createLiveRooms(key => {
|
|
60
|
+
const [account] = JSON.parse(key);
|
|
61
|
+
// Read as the keyed account (its own request context), never as whoever's write woke the bus.
|
|
62
|
+
return requestContext.run({ actor: account, csrf: "", returnTo: "/agents" }, () => {
|
|
63
|
+
const sees = (runId) => () => {
|
|
64
|
+
const run = store.getRun(runId);
|
|
65
|
+
const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
|
|
66
|
+
return run !== null && repo !== null && visible(repo) && store.accountCanAccess(account, repo) && localRunner !== undefined && run.runner === localRunner;
|
|
67
|
+
};
|
|
68
|
+
const tiles = agentsLiveOf(store, clock(), {
|
|
69
|
+
canSee: run => run.repo !== null && visible(run.repo) && store.accountCanAccess(account, run.repo),
|
|
70
|
+
localRunner,
|
|
71
|
+
});
|
|
72
|
+
const marks = tiles.filter(tile => tile.local).slice(0, LIVE_WATCHED_RUNS).map(tile => [tile.runId, marksOf(tile.runId, sees(tile.runId))]);
|
|
73
|
+
for (const [runId] of [...looked])
|
|
74
|
+
if (Date.now() - looked.get(runId).at > 10 * LIVE_POLL_MS)
|
|
75
|
+
looked.delete(runId);
|
|
76
|
+
// The digest's every run proved again from its current row after the looks: one that left view isn't in it.
|
|
77
|
+
const still = (runId) => {
|
|
78
|
+
const run = store.getRun(runId);
|
|
79
|
+
const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
|
|
80
|
+
return repo !== null && visible(repo) && store.accountCanAccess(account, repo);
|
|
81
|
+
};
|
|
82
|
+
const kept = new Set(tiles.filter(tile => still(tile.runId)).map(tile => tile.runId));
|
|
83
|
+
// Which of the viewer's own lead chats filed each run (agents-live-filed.ts): a link made or erased is a change too.
|
|
84
|
+
const filed = leadFiledLinks(store, account, [...kept]);
|
|
85
|
+
const seen = tiles.filter(tile => kept.has(tile.runId)).map(tile => [tile.runId, tile.state, tile.why, tile.endedAt, filed.get(tile.runId)?.thread ?? null]);
|
|
86
|
+
const shownMarks = marks.filter(([runId]) => kept.has(runId) && (!tiles.find(tile => tile.runId === runId)?.local || sees(runId)()));
|
|
87
|
+
return createHash("sha256").update(JSON.stringify([seen, shownMarks, input.leads?.(key) ?? null])).digest("base64url");
|
|
88
|
+
});
|
|
89
|
+
}, null, { bus: input.bus, safetyNetMs: LIVE_POLL_MS, onEmpty: key => input.forget?.(key) });
|
|
90
|
+
return { rooms, keyOf, looked: () => looked.size };
|
|
91
|
+
}
|
package/dist/server/chat.js
CHANGED
|
@@ -28,7 +28,7 @@ import { projectName } from "../project.js";
|
|
|
28
28
|
import { fileTaskProposal } from "../proposal.js";
|
|
29
29
|
import { providerName } from "../provider-auth.js";
|
|
30
30
|
import { validModelId } from "../provider.js";
|
|
31
|
-
import { loadOrCreateVapidKeys, validatePushEndpoint } from "../push.js";
|
|
31
|
+
import { loadOrCreateVapidKeys, loadVapidKeys, validatePushEndpoint } from "../push.js";
|
|
32
32
|
import { parseResultTab, RESULT_REVIEW_SCRIPT } from "../result-review.js";
|
|
33
33
|
import {} from "../store.js";
|
|
34
34
|
import { runActivityOf } from "../task-activity.js";
|
|
@@ -39,12 +39,12 @@ import { html, htmlString, postForm, reauth } from "../html.js";
|
|
|
39
39
|
import { requestContext } from "./request-context.js";
|
|
40
40
|
import { refuse, screen, whenTime } from "./chrome.js";
|
|
41
41
|
import { chatResultHref, chatReturnWithLatest, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
|
|
42
|
-
import { chatAckPage, chatPage, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
|
|
42
|
+
import { chatAckPage, chatPage, NOT_STARTED_SESSION, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
|
|
43
43
|
import {} from "./render-pages.js";
|
|
44
44
|
import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
|
|
45
45
|
import { browserCaller, oneLineUa } from "./session.js";
|
|
46
46
|
export function createChatHandlers(runtime) {
|
|
47
|
-
const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
|
|
47
|
+
const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
|
|
48
48
|
// The read-only refresh (package 2): the same JSON status as before —
|
|
49
49
|
// session binding, the send receipt, the live turn — plus a version
|
|
50
50
|
// of the DISPLAYED facts and, only when the caller's version differs,
|
|
@@ -54,25 +54,30 @@ export function createChatHandlers(runtime) {
|
|
|
54
54
|
async function mateStatus(ctx) {
|
|
55
55
|
const { url, response, now } = ctx;
|
|
56
56
|
const who = browserCaller(ctx.who);
|
|
57
|
-
const
|
|
57
|
+
const found = store.activeLeadSession(who.name);
|
|
58
58
|
const principal = leadPrincipal(who);
|
|
59
|
-
|
|
59
|
+
// Quiet page visits: a membership conversation not started yet is still a page to keep current (its task's
|
|
60
|
+
// progress and approval); it reads as session 0, and nothing here starts it.
|
|
61
|
+
const enabled = chatEnablement();
|
|
62
|
+
const notStarted = principal !== null && enabled.ok && enabled.billing === "subscription" && (found === null || found.ceilingDigest !== principal.ceilingDigest);
|
|
63
|
+
const session = notStarted ? null : found;
|
|
64
|
+
if (principal === null || (!notStarted && (session === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation))) {
|
|
60
65
|
return respond(response, 200, "application/json", JSON.stringify({ session: null }));
|
|
61
66
|
}
|
|
62
|
-
|
|
67
|
+
const sessionId = session?.id ?? NOT_STARTED_SESSION;
|
|
63
68
|
const request = url.searchParams.get("request") ?? "";
|
|
64
69
|
// Received: the engine took it, or the lead queue holds it (a queued acknowledgement lost on the way still frees the draft).
|
|
65
|
-
const received = /^[a-f0-9]{32}$/.test(request) && store.leadRequestReceived(session.id, request);
|
|
70
|
+
const received = session !== null && /^[a-f0-9]{32}$/.test(request) && store.leadRequestReceived(session.id, request);
|
|
66
71
|
const requestedTask = url.searchParams.get("task");
|
|
67
72
|
const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
|
|
68
73
|
if (requestedTask !== null && requestedTask !== "" && focusTask === null) {
|
|
69
74
|
// The lens's task is gone or no longer admitted: the page keeps its
|
|
70
75
|
// draft and says so; it never falls back to the unified thread.
|
|
71
|
-
return respond(response, 200, "application/json", JSON.stringify({ session:
|
|
76
|
+
return respond(response, 200, "application/json", JSON.stringify({ session: sessionId, task: requestedTask, unavailable: true, received }));
|
|
72
77
|
}
|
|
73
78
|
const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
|
|
74
79
|
if (chatProject === undefined)
|
|
75
|
-
return respond(response, 200, "application/json", JSON.stringify({ session:
|
|
80
|
+
return respond(response, 200, "application/json", JSON.stringify({ session: sessionId, unavailable: true, received }));
|
|
76
81
|
const rows = leadConversationRows(who, principal, focusTask, now, chatProject);
|
|
77
82
|
const version = leadChatVersion({ ...rows, focusTask });
|
|
78
83
|
const known = url.searchParams.get("version") ?? "";
|
|
@@ -80,12 +85,12 @@ export function createChatHandlers(runtime) {
|
|
|
80
85
|
const fragments = known === version
|
|
81
86
|
? null
|
|
82
87
|
: {
|
|
83
|
-
thread: htmlString(leadThreadHtml({ ...rows, focusTask, csrf, now, problem: takeLeadNote(csrf, session
|
|
88
|
+
thread: htmlString(leadThreadHtml({ ...rows, focusTask, csrf, now, problem: takeLeadNote(csrf, session?.id ?? null), chatProject })),
|
|
84
89
|
after: htmlString(leadAfterComposerHtml({ messages: rows.messages, pending: rows.pending, focusTask })),
|
|
85
90
|
live: focusTask === null ? null : htmlString(taskChatLiveRegion(focusTask, csrf, true, rows.pending !== null)),
|
|
86
91
|
};
|
|
87
92
|
return respond(response, 200, "application/json", JSON.stringify({
|
|
88
|
-
session:
|
|
93
|
+
session: sessionId,
|
|
89
94
|
task: focusTask?.id ?? "",
|
|
90
95
|
version,
|
|
91
96
|
pending: rows.pending !== null,
|
|
@@ -145,9 +150,8 @@ export function createChatHandlers(runtime) {
|
|
|
145
150
|
chrome: chromeFor(null, 'chat', undefined, 'all'), workspace: { team: reply.snapshot, notices: [DEPRECATED_PAGE.team, ...(said ? [said] : [])] },
|
|
146
151
|
}));
|
|
147
152
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
store.sweepCoordinatorProposals(now);
|
|
153
|
+
// Settling a turn past its deadline (its spend) and expiring old proposals are the background passes' work, never a
|
|
154
|
+
// page visit's (docs/plans/get-side-effects.md); the lead's follow pass sweeps every few seconds.
|
|
151
155
|
sweepChatDrafts(Date.now());
|
|
152
156
|
const requestedTask = url.searchParams.get("task");
|
|
153
157
|
const focusTask = taskChatFocus(requestedTask, now, who, { mintNonce: !requestContext.getStore()?.workspaceRead });
|
|
@@ -237,24 +241,22 @@ export function createChatHandlers(runtime) {
|
|
|
237
241
|
// The mate (mate arc §5): while a mate session is live, /chat IS the
|
|
238
242
|
// thread — the same rows the CLI reads. Without one, fleet chat as
|
|
239
243
|
// before, plus the card that mints a session.
|
|
240
|
-
|
|
244
|
+
const leadSession = enabled.ok && who.role === "approver" ? store.activeLeadSession(who.name) : null;
|
|
241
245
|
const principal = enabled.ok && who.role === "approver" ? leadPrincipal(who) : null;
|
|
242
246
|
// Chat opens ready to talk (2026-09-23): an approver in good standing
|
|
243
|
-
// needs no second password to start their own conversation.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// the
|
|
247
|
-
//
|
|
248
|
-
//
|
|
247
|
+
// needs no second password to start their own conversation. Quiet page
|
|
248
|
+
// visits (2026-10-27): a visit only shows it. With no membership
|
|
249
|
+
// conversation yet (or only one under another ceiling), the page shows
|
|
250
|
+
// the composer and the first message starts one (chatSend); nothing
|
|
251
|
+
// here writes. Direct-API chat keeps a one-tap Start with its visible
|
|
252
|
+
// spending limit (no password).
|
|
249
253
|
const settingsView = url.searchParams.get("settings") === "1";
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
leadSession = store.activeLeadSession(who.name);
|
|
253
|
-
}
|
|
254
|
+
const notStarted = !settingsView && enabled.ok && enabled.billing === "subscription" && principal !== null &&
|
|
255
|
+
(leadSession === null || leadSession.ceilingDigest !== principal.ceilingDigest);
|
|
254
256
|
// A session under another ceiling is not continuable from here; a GET
|
|
255
257
|
// writes nothing (slice-2 review, finding 7) — the mint card below
|
|
256
258
|
// starts a new conversation, and minting ends the old session.
|
|
257
|
-
const ceilingStale = enabled.ok && leadSession !== null && principal !== null && leadSession.ceilingDigest !== principal.ceilingDigest;
|
|
259
|
+
const ceilingStale = !notStarted && enabled.ok && leadSession !== null && principal !== null && leadSession.ceilingDigest !== principal.ceilingDigest;
|
|
258
260
|
const catchUp = leadBriefHtml(assignmentCatchUp(store, now, { principal: "operator", repos }, { limit: 6 }, evidenceRoot));
|
|
259
261
|
const chatProjects = repos.map((repo, index) => {
|
|
260
262
|
let peek = null;
|
|
@@ -288,11 +290,12 @@ export function createChatHandlers(runtime) {
|
|
|
288
290
|
const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
|
|
289
291
|
: { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
|
|
290
292
|
workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
|
|
291
|
-
if (enabled.ok &&
|
|
293
|
+
if (enabled.ok && principal !== null && (notStarted || (leadSession !== null && !ceilingStale))) {
|
|
292
294
|
{
|
|
293
|
-
const said = takeLeadNote(who.session.csrf, leadSession
|
|
295
|
+
const said = takeLeadNote(who.session.csrf, notStarted ? null : leadSession?.id ?? null);
|
|
294
296
|
return sendScreen(response, 200, withFirstRun(leadPage(chromeFor(null, "chat", undefined, "all"), {
|
|
295
|
-
session: leadSession,
|
|
297
|
+
session: notStarted ? null : leadSession,
|
|
298
|
+
approver: who.name,
|
|
296
299
|
...leadConversationRows(who, principal, focusTask, now, chatProject),
|
|
297
300
|
chatProject,
|
|
298
301
|
latched,
|
|
@@ -336,7 +339,7 @@ export function createChatHandlers(runtime) {
|
|
|
336
339
|
tail: found === null || found.source === "environment" ? null : redactToken(found.key),
|
|
337
340
|
};
|
|
338
341
|
}),
|
|
339
|
-
openrouterModels: (
|
|
342
|
+
openrouterModels: chatCatalogKnown()?.map(one => one.id) ?? null,
|
|
340
343
|
liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
|
|
341
344
|
csrf: who.session.csrf,
|
|
342
345
|
problem: url.searchParams.get("said") ??
|
|
@@ -420,22 +423,39 @@ export function createChatHandlers(runtime) {
|
|
|
420
423
|
const { who, response } = ctx;
|
|
421
424
|
if (who.via !== "cookie")
|
|
422
425
|
return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
|
|
423
|
-
|
|
426
|
+
// The pair is made as the console starts (docs/plans/get-side-effects.md); this only reads it.
|
|
427
|
+
const keys = options.telegramTokenFile === undefined ? null : loadVapidKeys(dirname(options.telegramTokenFile));
|
|
424
428
|
response.setHeader("cache-control", "no-store");
|
|
429
|
+
if (keys === null)
|
|
430
|
+
return respond(response, 503, "application/json", JSON.stringify({ error: "push is not set up on this console" }));
|
|
425
431
|
return respond(response, 200, "application/json", JSON.stringify({ key: keys.publicKey }));
|
|
426
432
|
}
|
|
427
|
-
//
|
|
428
|
-
//
|
|
433
|
+
// What this computer's agent CLIs said last, and whether the lead is on. A read: the answer comes from the background
|
|
434
|
+
// checks or a Check now (POST /lead/check), never from this request (docs/plans/get-side-effects.md).
|
|
435
|
+
const leadState = () => JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: runtime.localSignIn?.signedIn ?? null,
|
|
436
|
+
checkedAt: runtime.localSignIn === null ? null : new Date(runtime.localSignIn.at).toISOString(), command: agentSignInCommand() });
|
|
429
437
|
async function leadStatus(ctx) {
|
|
430
438
|
const { who, response } = ctx;
|
|
431
439
|
if (who.via !== "cookie")
|
|
432
440
|
return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
|
|
441
|
+
response.setHeader("cache-control", "no-store");
|
|
442
|
+
return respond(response, 200, "application/json", leadState());
|
|
443
|
+
}
|
|
444
|
+
// While no agent is signed in, Chat asks this computer again on its own (onboarding), and Settings → Lead has Check
|
|
445
|
+
// now: a fresh, non-spending sign-in check at most every few seconds, and the lead turns on as soon as one is found
|
|
446
|
+
// (the lead-by-default rule). A POST with the person's form token, so no prefetch or link preview can set it off.
|
|
447
|
+
async function leadCheck(ctx) {
|
|
448
|
+
const { response, posted } = ctx;
|
|
449
|
+
const body = readForm(posted, CONSOLE_FORMS.leadCheck);
|
|
433
450
|
if (Date.now() - runtime.leadRecheckedAt > 4_000) {
|
|
434
451
|
runtime.leadRecheckedAt = Date.now();
|
|
435
452
|
await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
|
|
436
453
|
}
|
|
454
|
+
const back = body.get("return");
|
|
455
|
+
if (back !== null)
|
|
456
|
+
return redirect(response, safeReturn(back));
|
|
437
457
|
response.setHeader("cache-control", "no-store");
|
|
438
|
-
return respond(response, 200, "application/json",
|
|
458
|
+
return respond(response, 200, "application/json", leadState());
|
|
439
459
|
}
|
|
440
460
|
async function taskStatus(ctx) {
|
|
441
461
|
const { url, who, response, now } = ctx;
|
|
@@ -663,7 +683,9 @@ export function createChatHandlers(runtime) {
|
|
|
663
683
|
const body = readForm(posted, CONSOLE_FORMS.leadFollow);
|
|
664
684
|
if (who.via !== "cookie")
|
|
665
685
|
return refuse(response, who, 403, "Open the conversation to change automatic updates.");
|
|
666
|
-
const principal = leadPrincipal(who), session = store.activeLeadSession(who.name)
|
|
686
|
+
const principal = leadPrincipal(who), session = store.activeLeadSession(who.name);
|
|
687
|
+
// A conversation its first message started from a task or project page has no main thread yet: this act opens it.
|
|
688
|
+
const thread = principal && session && session.ceilingDigest === principal.ceilingDigest ? store.openLeadThread(who.name, principal.ceilingDigest, now).thread : null;
|
|
667
689
|
if (!principal || !session || !thread || !configureLeadFollow(store, principal, session, thread, body.get("enabled") === "yes", now))
|
|
668
690
|
return refuse(response, who, 409, "Conversation access changed. Start chat again.", "/chat");
|
|
669
691
|
return redirect(response, safeChatReturn(body.get("return")));
|
|
@@ -875,10 +897,52 @@ export function createChatHandlers(runtime) {
|
|
|
875
897
|
// A live mate session: the message is a mate turn — no password, the
|
|
876
898
|
// session's ceremony already covered it (§1); the engine refuses on
|
|
877
899
|
// its own terms and the thread shows why.
|
|
878
|
-
|
|
900
|
+
let leadSession = who.role === "approver" ? store.activeLeadSession(who.name) : null;
|
|
901
|
+
// Quiet page visits: a membership conversation starts with its first message, never with a page view. A page
|
|
902
|
+
// shown before one existed binds its sends to session 0; that send starts it (the same approver standing a visit
|
|
903
|
+
// used to need), and a retry or double submit of the same request lands in the conversation it started.
|
|
904
|
+
let startedHere = false;
|
|
905
|
+
const binding = body.getAll("request-session");
|
|
906
|
+
const unbound = binding.length === 0 || (binding.length === 1 && binding[0] === String(NOT_STARTED_SESSION));
|
|
907
|
+
if (who.role === "approver" && enabled.billing === "subscription" && unbound) {
|
|
908
|
+
const principal = leadPrincipal(who);
|
|
909
|
+
if (principal === null)
|
|
910
|
+
return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
|
|
911
|
+
const sentRequest = body.get("request");
|
|
912
|
+
if (sentRequest !== null && (body.getAll("request").length !== 1 || !/^[a-f0-9]{32}$/.test(sentRequest)))
|
|
913
|
+
return said(409, "This conversation changed. Reload it before sending your message.");
|
|
914
|
+
const usable = leadSession !== null && leadSession.ceilingDigest === principal.ceilingDigest && leadSession.approverGeneration === principal.generation;
|
|
915
|
+
// The conversation this very request started, if it started one: a retry or double submit lands only there.
|
|
916
|
+
const startedBy = sentRequest === null ? null : store.leadStartReceipt(who.name, sentRequest);
|
|
917
|
+
if (startedBy !== null) {
|
|
918
|
+
if (!usable || leadSession === null || leadSession.id !== startedBy.session)
|
|
919
|
+
return said(409, "This conversation ended or changed. Reload it before sending your message.", leadSession?.id ?? null);
|
|
920
|
+
startedHere = true;
|
|
921
|
+
}
|
|
922
|
+
else if (usable && leadSession !== null) {
|
|
923
|
+
// A page that showed no conversation never sends into one it did not start.
|
|
924
|
+
if (binding.length === 1)
|
|
925
|
+
return said(409, "This conversation changed. Reload it before sending your message.", leadSession.id);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
// Nothing starts for a message that would be refused anyway.
|
|
929
|
+
const message = (body.get("message") ?? "").trim();
|
|
930
|
+
if (message === "" || message.length > LEAD_MESSAGE_MAX_CHARS)
|
|
931
|
+
return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`);
|
|
932
|
+
if (scanForSecrets(message).length > 0)
|
|
933
|
+
return said(400, "that looks like a credential — chat never forwards or stores those");
|
|
934
|
+
const scope = chatScopeOf(focusTask, chatProject);
|
|
935
|
+
// The start and its record are one write: a retry finds it, and never starts a second conversation.
|
|
936
|
+
const started = startLeadConversation(who, principal, enabled, 0, false, now, scope, sentRequest ?? undefined);
|
|
937
|
+
leadSession = store.activeLeadSession(who.name);
|
|
938
|
+
if (leadSession === null || leadSession.id !== started)
|
|
939
|
+
return said(409, "This conversation changed. Reload it before sending your message.");
|
|
940
|
+
startedHere = true;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
879
943
|
if (leadSession !== null) {
|
|
880
944
|
const sentRequest = body.get("request");
|
|
881
|
-
if (sentRequest !== null && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(leadSession.id))) {
|
|
945
|
+
if (sentRequest !== null && !startedHere && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(leadSession.id))) {
|
|
882
946
|
return said(409, "This conversation changed. Reload it before sending your message.");
|
|
883
947
|
}
|
|
884
948
|
// Every send has one identity (a form without one gets it here): a message that waits is known by it.
|
|
@@ -1108,7 +1172,7 @@ export function createChatHandlers(runtime) {
|
|
|
1108
1172
|
intro: HOW_IT_WORKS,
|
|
1109
1173
|
lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
|
|
1110
1174
|
// With no lead yet and no agent signed in, the page asks this computer again on its own.
|
|
1111
|
-
recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/
|
|
1175
|
+
recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/check" : null,
|
|
1112
1176
|
};
|
|
1113
1177
|
}
|
|
1114
1178
|
/** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
|
|
@@ -1136,16 +1200,18 @@ export function createChatHandlers(runtime) {
|
|
|
1136
1200
|
const previous = liveTurns.get(thread);
|
|
1137
1201
|
if (previous?.expiry)
|
|
1138
1202
|
clearTimeout(previous.expiry);
|
|
1139
|
-
const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
|
|
1203
|
+
const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [] };
|
|
1140
1204
|
liveTurns.set(thread, live);
|
|
1141
1205
|
// A page that has this conversation open follows the reply (GET /live, its chat room).
|
|
1142
1206
|
runtime.liveTurnStarted(thread);
|
|
1143
|
-
const
|
|
1207
|
+
const newStep = () => { live.steps.push({ tools: [], toolCalls: [], text: "" }); live.stepAt.push(clock().getTime()); };
|
|
1208
|
+
const current = () => live.steps.at(-1) ?? (newStep(), live.steps.at(-1));
|
|
1144
1209
|
const progress = (event) => {
|
|
1145
1210
|
if (live.done)
|
|
1146
1211
|
return;
|
|
1212
|
+
live.turn ??= event.turn;
|
|
1147
1213
|
if (event.kind === "step")
|
|
1148
|
-
|
|
1214
|
+
newStep();
|
|
1149
1215
|
else if (event.kind === "tool") {
|
|
1150
1216
|
current().tools.push(event.label);
|
|
1151
1217
|
current().toolCalls.push({ id: event.id, label: event.label, state: "running" });
|
|
@@ -1159,8 +1225,10 @@ export function createChatHandlers(runtime) {
|
|
|
1159
1225
|
}
|
|
1160
1226
|
}
|
|
1161
1227
|
}
|
|
1162
|
-
else if (event.kind === "text")
|
|
1228
|
+
else if (event.kind === "text") {
|
|
1163
1229
|
current().text = event.text;
|
|
1230
|
+
(live.confirmed ??= [])[live.steps.length - 1] = event.final === true;
|
|
1231
|
+
}
|
|
1164
1232
|
for (const listener of live.listeners)
|
|
1165
1233
|
listener();
|
|
1166
1234
|
};
|
|
@@ -1175,6 +1243,7 @@ export function createChatHandlers(runtime) {
|
|
|
1175
1243
|
return;
|
|
1176
1244
|
live.done = true;
|
|
1177
1245
|
live.ok = ok;
|
|
1246
|
+
live.endedAt = clock().getTime();
|
|
1178
1247
|
for (const listener of live.listeners)
|
|
1179
1248
|
listener();
|
|
1180
1249
|
live.expiry = setTimeout(() => { if (liveTurns.get(thread) === live)
|
|
@@ -1415,18 +1484,10 @@ export function createChatHandlers(runtime) {
|
|
|
1415
1484
|
planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
|
|
1416
1485
|
};
|
|
1417
1486
|
}
|
|
1487
|
+
/** The first tasks last found for a project (docs/plans/get-side-effects.md): the background checks ask gh and
|
|
1488
|
+
* git grep while the first run is open; a page shows what they found, or the generic three. */
|
|
1418
1489
|
function firstTasksFor(repo) {
|
|
1419
|
-
|
|
1420
|
-
let entry = firstTasks.get(repo);
|
|
1421
|
-
if (entry === undefined || (entry.until <= at && !entry.reading)) {
|
|
1422
|
-
const reading = { until: at + 10 * 60_000, found: entry?.found ?? null, reading: true };
|
|
1423
|
-
firstTasks.set(repo, reading);
|
|
1424
|
-
entry = reading;
|
|
1425
|
-
void findFirstTasks(repo, options.firstTaskRunner ?? execRun)
|
|
1426
|
-
.then(found => { reading.found = found; }, () => { })
|
|
1427
|
-
.finally(() => { reading.reading = false; });
|
|
1428
|
-
}
|
|
1429
|
-
return entry.found ?? firstTaskSuggestions({ issues: [], todos: [] });
|
|
1490
|
+
return firstTasks.get(repo)?.found ?? firstTaskSuggestions({ issues: [], todos: [] });
|
|
1430
1491
|
}
|
|
1431
1492
|
function approverCandidateCount(approver) {
|
|
1432
1493
|
let count = 0;
|
|
@@ -1464,6 +1525,7 @@ export function createChatHandlers(runtime) {
|
|
|
1464
1525
|
"chat.action": sharedActionReview,
|
|
1465
1526
|
"push.key": pushKey,
|
|
1466
1527
|
"lead.status": leadStatus,
|
|
1528
|
+
"lead.check": leadCheck,
|
|
1467
1529
|
"chat.task-status": taskStatus,
|
|
1468
1530
|
"chat.demo.ask": demoAsk,
|
|
1469
1531
|
"chat.demo.approve": demoAct("approve"),
|
package/dist/server/chrome.js
CHANGED
|
@@ -396,7 +396,7 @@ export function chromeScript() {
|
|
|
396
396
|
`if(ev.key==="?"){toggleHelp();ev.preventDefault();return;}` +
|
|
397
397
|
`if(ev.key==="j"||ev.key==="k"){rove(ev.key==="j"?1:-1,ev);return;}` +
|
|
398
398
|
`if(pending==="g"){pending=null;` +
|
|
399
|
-
`var map={b:"/board",i:"/",w:"/workbench",d:"/done",q:"/board?view=order",f:"/fleet",t:"/
|
|
399
|
+
`var map={b:"/board",i:"/",w:"/workbench",d:"/done",q:"/board?view=order",f:"/fleet",t:"/work",a:"/activity",p:"/projects"};` +
|
|
400
400
|
`if(map[ev.key]){go(map[ev.key]);ev.preventDefault();}return;}` +
|
|
401
401
|
`if(ev.key==="g"){pending="g";setTimeout(function(){pending=null;},800);}});` +
|
|
402
402
|
`})();`);
|
|
@@ -717,7 +717,7 @@ export function workToolRows(scoped = false, _offersChat = false, offersCode = f
|
|
|
717
717
|
...(offersCode ? [{ key: "code", href: "/term", label: "Live", hint: "your own terminals: Claude Code, Codex or a shell" }] : []),
|
|
718
718
|
{ key: "inbox", href: "/inbox", label: "Inbox", hint: "questions, approvals, and repairs to act on" },
|
|
719
719
|
{ key: "board", href: "/board", label: "Board", hint: "lanes by state, with the order view" },
|
|
720
|
-
{ key: "tasks", href: "/
|
|
720
|
+
{ key: "tasks", href: "/work", label: "Task list", hint: "everything, filterable by state" },
|
|
721
721
|
{ key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
|
|
722
722
|
{ key: "workbench", href: "/workbench", label: "Portfolio", hint: "every project and live build in one place" },
|
|
723
723
|
{ key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
|