toolroll 0.9.10 → 0.9.12
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 +25 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +3 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +20 -18
- package/dist/browser-workspace.d.ts +61 -0
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery.js +21 -0
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +3 -1
- package/dist/chat-voice.js +13 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/coding-update.d.ts +13 -0
- package/dist/coding-update.js +68 -0
- package/dist/desktop-update.d.ts +7 -0
- package/dist/desktop-update.js +42 -1
- package/dist/flow-triggers.js +17 -1
- package/dist/guides.js +9 -0
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +13 -3
- package/dist/lead-voice.d.ts +81 -0
- package/dist/lead-voice.js +275 -0
- package/dist/needs-you.d.ts +7 -0
- package/dist/needs-you.js +9 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +186 -38
- package/dist/serve.d.ts +5 -1
- package/dist/serve.js +447 -226
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +15 -1
- package/dist/store.js +38 -1
- package/dist/surface.js +8 -1
- package/dist/task-status.d.ts +6 -0
- package/dist/task-status.js +19 -2
- package/dist/team-cli.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.js +7 -0
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram.d.ts +14 -0
- package/dist/telegram.js +105 -12
- package/dist/test-temp.js +1 -1
- package/dist/toolroll-update.d.ts +3 -0
- package/dist/toolroll-update.js +13 -2
- package/dist/work-index.d.ts +9 -0
- package/dist/work-index.js +57 -20
- package/dist/work-summary.d.ts +4 -1
- package/dist/workspace-ui.d.ts +25 -0
- package/dist/workspace-ui.js +20 -1
- package/package.json +1 -1
package/dist/setup-guide.d.ts
CHANGED
|
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
|
|
|
15
15
|
} | null;
|
|
16
16
|
/** Browser installation may write only this known file inside its project. */
|
|
17
17
|
export declare function previewProjectInstructions(repo: string): {
|
|
18
|
-
content?: never;
|
|
19
18
|
ok: false;
|
|
20
19
|
message: string;
|
|
21
20
|
plan?: never;
|
|
21
|
+
content?: never;
|
|
22
22
|
fingerprint?: never;
|
|
23
23
|
installed?: never;
|
|
24
24
|
} | {
|
|
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
|
|
|
30
30
|
installed: boolean;
|
|
31
31
|
};
|
|
32
32
|
export declare function addProjectInstructions(repo: string, fingerprint: string): {
|
|
33
|
-
content?: never;
|
|
34
33
|
ok: false;
|
|
35
34
|
message: string;
|
|
36
35
|
plan?: never;
|
|
36
|
+
content?: never;
|
|
37
37
|
fingerprint?: never;
|
|
38
38
|
installed?: never;
|
|
39
39
|
} | import("./skills.js").InstallResult;
|
package/dist/store.d.ts
CHANGED
|
@@ -7294,7 +7294,7 @@ export declare class Store {
|
|
|
7294
7294
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
7295
7295
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
7296
7296
|
*/
|
|
7297
|
-
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date): TelegramConversation | null;
|
|
7297
|
+
claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date, only?: "turns" | "replies"): TelegramConversation | null;
|
|
7298
7298
|
/** Extend a held claim; false means it lapsed and somebody else may hold the row. */
|
|
7299
7299
|
renewTelegramConversation(id: number, owner: string, ttlMs: number, now: Date): boolean;
|
|
7300
7300
|
/**
|
|
@@ -7321,6 +7321,20 @@ export declare class Store {
|
|
|
7321
7321
|
outcome: string;
|
|
7322
7322
|
nextAttemptAt: string;
|
|
7323
7323
|
}, now: Date): boolean;
|
|
7324
|
+
/**
|
|
7325
|
+
* Replies the assistant wrote that still have not reached the person
|
|
7326
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
7327
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
7328
|
+
* written, and the last send error.
|
|
7329
|
+
*/
|
|
7330
|
+
unsentTelegramReplies(botId: string | null, approver: string | null, since: Date): {
|
|
7331
|
+
conversation: number;
|
|
7332
|
+
approver: string;
|
|
7333
|
+
since: string;
|
|
7334
|
+
error: string | null;
|
|
7335
|
+
}[];
|
|
7336
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
7337
|
+
retryTelegramReplies(approver: string | null, now: Date): number;
|
|
7324
7338
|
/**
|
|
7325
7339
|
* Persist the whole outbound half of a turn — every reply part and every
|
|
7326
7340
|
* card, with the cards' tokens already minted — in one transaction, BEFORE
|
package/dist/store.js
CHANGED
|
@@ -657,6 +657,9 @@ export function isTelegramProgressNotification(row) {
|
|
|
657
657
|
return false;
|
|
658
658
|
if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
|
|
659
659
|
return true;
|
|
660
|
+
// The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
|
|
661
|
+
if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
|
|
662
|
+
return true;
|
|
660
663
|
return isLifecycleNotification(row) &&
|
|
661
664
|
["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
|
|
662
665
|
}
|
|
@@ -21621,14 +21624,18 @@ export class Store {
|
|
|
21621
21624
|
* under a claim that lapsed (a crash mid-turn), and not deferred past now.
|
|
21622
21625
|
* One at a time per bot — the mate runs one turn per approver anyway.
|
|
21623
21626
|
*/
|
|
21624
|
-
claimTelegramConversation(botId, owner, ttlMs, now) {
|
|
21627
|
+
claimTelegramConversation(botId, owner, ttlMs, now, only) {
|
|
21625
21628
|
return this.transact(() => {
|
|
21626
21629
|
const stamp = now.toISOString();
|
|
21630
|
+
// "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
|
|
21631
|
+
const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
|
|
21632
|
+
const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
|
|
21627
21633
|
const row = this.db
|
|
21628
21634
|
.prepare(`SELECT * FROM telegram_conversation
|
|
21629
21635
|
WHERE bot_id = ? AND state IN ('queued','running')
|
|
21630
21636
|
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
21631
21637
|
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
21638
|
+
${which}
|
|
21632
21639
|
ORDER BY id LIMIT 1`)
|
|
21633
21640
|
.get(botId, stamp, stamp);
|
|
21634
21641
|
if (row === undefined)
|
|
@@ -21686,6 +21693,31 @@ export class Store {
|
|
|
21686
21693
|
.run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
|
|
21687
21694
|
return Number(changes) === 1;
|
|
21688
21695
|
}
|
|
21696
|
+
/**
|
|
21697
|
+
* Replies the assistant wrote that still have not reached the person
|
|
21698
|
+
* `since` ago or longer (status and the console show them with a Retry):
|
|
21699
|
+
* the message's row, whose it is, when the oldest unsent part was
|
|
21700
|
+
* written, and the last send error.
|
|
21701
|
+
*/
|
|
21702
|
+
unsentTelegramReplies(botId, approver, since) {
|
|
21703
|
+
return this.db
|
|
21704
|
+
.prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
|
|
21705
|
+
(SELECT q.last_error FROM telegram_conversation_part q WHERE q.conversation = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
|
|
21706
|
+
FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
|
|
21707
|
+
WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
|
|
21708
|
+
GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
|
|
21709
|
+
.all(botId, botId, approver, approver, since.toISOString())
|
|
21710
|
+
.map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
|
|
21711
|
+
}
|
|
21712
|
+
/** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
|
|
21713
|
+
retryTelegramReplies(approver, now) {
|
|
21714
|
+
const { changes } = this.db
|
|
21715
|
+
.prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
|
|
21716
|
+
WHERE state = 'queued' AND (? IS NULL OR approver = ?)
|
|
21717
|
+
AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
|
|
21718
|
+
.run(now.toISOString(), approver, approver);
|
|
21719
|
+
return Number(changes);
|
|
21720
|
+
}
|
|
21689
21721
|
// ---- the outbound parts (v63) ------------------------------------------------
|
|
21690
21722
|
/**
|
|
21691
21723
|
* Persist the whole outbound half of a turn — every reply part and every
|
|
@@ -22224,7 +22256,12 @@ export class Store {
|
|
|
22224
22256
|
return true;
|
|
22225
22257
|
if (row.recipient !== null && row.kind === "task-ask")
|
|
22226
22258
|
return true;
|
|
22259
|
+
// The lead's own words reach the person it speaks for, and its claim repaints only that person's card (lead-voice.ts).
|
|
22260
|
+
if (row.kind === "lead-say")
|
|
22261
|
+
return row.recipient === account;
|
|
22227
22262
|
const actor = this.notificationActor(row.id);
|
|
22263
|
+
if (row.kind === "lead-on-it" || row.kind === "lead-lapsed")
|
|
22264
|
+
return actor?.lead === true && actor.account === account;
|
|
22228
22265
|
if (actor !== null && actor.account === account)
|
|
22229
22266
|
return false;
|
|
22230
22267
|
if (row.recipient !== null)
|
package/dist/surface.js
CHANGED
|
@@ -194,7 +194,7 @@ export const COMMAND_GUIDE = [
|
|
|
194
194
|
invocation: `assignment ${action}`,
|
|
195
195
|
synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
|
|
196
196
|
: action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
|
|
197
|
-
: action === "claim" ? "record
|
|
197
|
+
: action === "claim" ? "record lead ownership; with the lead token the task reads \"Your lead is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
|
|
198
198
|
: action === "brief" ? "catch up from current assignments and project knowledge in the local database"
|
|
199
199
|
: action === "inbox" ? "receive or replay a saved batch of lead status updates"
|
|
200
200
|
: action === "ack" ? "acknowledge delivery of an inbox batch; leaves task completion unchanged"
|
|
@@ -215,6 +215,13 @@ export const COMMAND_GUIDE = [
|
|
|
215
215
|
})),
|
|
216
216
|
operator("task state", "set a task's state by hand — an operator correction, not a workflow step (--replaced-by <id> with cancelled)"),
|
|
217
217
|
operator("lead token", "mint the lead's credential behind your password (--revoke ends it): its own work pings nobody"),
|
|
218
|
+
{ invocation: "lead say", synopsis: "as the lead: one short message in its person's chat from \"Your lead\"; several within two minutes are one message",
|
|
219
|
+
audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
|
|
220
|
+
positionals: [{ name: "text", required: true, meaning: "one short line in plain words" }],
|
|
221
|
+
flags: [jsonFlag, dbFlag,
|
|
222
|
+
{ name: "task", takesValue: true, meaning: "the task it is about" },
|
|
223
|
+
{ name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
|
|
224
|
+
notableReasons: ["usage", "unknown-task", "unauthenticated"] },
|
|
218
225
|
{ invocation: "task block", synopsis: "make one task wait for another", audience: "agent", agentMayInvoke: true, mutation: "keyed",
|
|
219
226
|
positionals: [{ name: "id", required: true, meaning: "the waiting task" }],
|
|
220
227
|
flags: [jsonFlag, dbFlag, keyFlag, { name: "on", takesValue: true, meaning: "the blocker's id" }],
|
package/dist/task-status.d.ts
CHANGED
|
@@ -107,8 +107,14 @@ export type TaskStatusFacts = {
|
|
|
107
107
|
};
|
|
108
108
|
/** Exact technical reasons, shown only on request. */
|
|
109
109
|
why?: readonly string[];
|
|
110
|
+
/** The person's lead took this on (lead-voice.ts): "on-it" reads "Your lead is on it" and needs nobody; "lapsed"
|
|
111
|
+
* (two hours without a lead act) is back with the person, and says so. */
|
|
112
|
+
lead?: "on-it" | "lapsed" | null;
|
|
110
113
|
};
|
|
111
114
|
export declare function headlineOf(facts: Pick<TaskStatusFacts, "stage" | "checks" | "report">): Headline;
|
|
115
|
+
/** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
|
|
116
|
+
export declare const LEAD_ON_IT = "Your lead is on it.";
|
|
117
|
+
export declare const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
|
|
112
118
|
export declare function taskStatusOf(facts: TaskStatusFacts): TaskStatus;
|
|
113
119
|
export declare function plainReasonOf(stage: TaskStage, code: string, detail: string | null | undefined): string | null;
|
|
114
120
|
export declare function stageOfCode(code: string, options?: {
|
package/dist/task-status.js
CHANGED
|
@@ -133,13 +133,29 @@ function detailsOf(headline, facts) {
|
|
|
133
133
|
}
|
|
134
134
|
return rows;
|
|
135
135
|
}
|
|
136
|
+
/** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
|
|
137
|
+
export const LEAD_ON_IT = "Your lead is on it.";
|
|
138
|
+
export const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
|
|
139
|
+
/** The headlines a lead's claim speaks for: the ones that would otherwise wait on a person. */
|
|
140
|
+
const LEAD_HEADLINES = new Set(["Needs you", "Failed", "Ready for review", "Stopped"]);
|
|
141
|
+
function leadSentence(headline, facts) {
|
|
142
|
+
const sha = short(facts.checks?.head ?? null);
|
|
143
|
+
// A failed check stays said: the claim changes who acts next, never the result.
|
|
144
|
+
if (headline === "Failed" && facts.checks?.status === "failed")
|
|
145
|
+
return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${LEAD_ON_IT}`;
|
|
146
|
+
return LEAD_ON_IT;
|
|
147
|
+
}
|
|
136
148
|
export function taskStatusOf(facts) {
|
|
137
|
-
const
|
|
149
|
+
const read = headlineOf(facts);
|
|
150
|
+
const lead = facts.lead != null && LEAD_HEADLINES.has(read) ? facts.lead : null;
|
|
151
|
+
// The lead has it: nothing waits on the person, so a Needs you reads Waiting.
|
|
152
|
+
const headline = lead === "on-it" && read === "Needs you" ? "Waiting" : read;
|
|
138
153
|
// Every Needs you carries the one action that resolves it, worded the same everywhere.
|
|
139
154
|
const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
|
|
140
155
|
const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
|
|
141
156
|
const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
|
|
142
|
-
|
|
157
|
+
const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${LEAD_LAPSED} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
|
|
158
|
+
return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
|
|
143
159
|
primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
|
|
144
160
|
}
|
|
145
161
|
// ---- reading the existing projections ----------------------------------------
|
|
@@ -287,6 +303,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
|
|
|
287
303
|
action: options.action ?? null,
|
|
288
304
|
...(options.links === undefined ? {} : { links: options.links }),
|
|
289
305
|
why: options.why ?? [],
|
|
306
|
+
...(assignment.lead == null ? {} : { lead: assignment.lead.state }),
|
|
290
307
|
};
|
|
291
308
|
}
|
|
292
309
|
// ---- rendering helpers shared by the server pages and chat cards ------------
|
package/dist/team-cli.js
CHANGED
|
@@ -306,7 +306,7 @@ function render(reply, listing) {
|
|
|
306
306
|
export async function maybeRunTeamCommand(argv, write, options = {}) {
|
|
307
307
|
const command = argv[0];
|
|
308
308
|
// `lead token` is local: the credential a lead agent acts with on this computer.
|
|
309
|
-
if (command === 'lead' && argv[1] === 'token')
|
|
309
|
+
if (command === 'lead' && (argv[1] === 'token' || argv[1] === 'say'))
|
|
310
310
|
return null;
|
|
311
311
|
if (!['connect', 'lead', 'conversation', 'chat', 'brief'].includes(command ?? '') && !argv.includes('--profile'))
|
|
312
312
|
return null;
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export declare function splitParts(text: string): string[];
|
|
|
122
122
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
123
123
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
124
124
|
* flood cannot pin one pass forever.
|
|
125
|
+
*
|
|
126
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
127
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
128
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
129
|
+
* place, in the same order and over the same connection as notifications,
|
|
130
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
125
131
|
*/
|
|
126
132
|
export declare function processTelegramConversations(args: {
|
|
127
133
|
store: Store;
|
|
@@ -134,4 +140,5 @@ export declare function processTelegramConversations(args: {
|
|
|
134
140
|
report: ConversationReport;
|
|
135
141
|
signal?: AbortSignal;
|
|
136
142
|
limit?: number;
|
|
143
|
+
only?: "turns" | "replies";
|
|
137
144
|
}): Promise<void>;
|
package/dist/telegram-mate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
|
|
2
|
-
import { taskInCeiling, mirrorToTaskChat, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
2
|
+
import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
|
|
3
3
|
export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
|
|
4
4
|
/**
|
|
5
5
|
* The paired phone as one more way to use the same assistant.
|
|
@@ -299,11 +299,17 @@ export function splitParts(text) {
|
|
|
299
299
|
* outside any SQLite transaction. The caller holds the poll lease and
|
|
300
300
|
* renews it; the row's own claim is renewed here. Bounded per call so a
|
|
301
301
|
* flood cannot pin one pass forever.
|
|
302
|
+
*
|
|
303
|
+
* `only` splits the work for the follower: "turns" runs the model and
|
|
304
|
+
* persists the reply but leaves the sending to the bridge's own cycle,
|
|
305
|
+
* which calls back with "replies" — so a reply goes out from the same
|
|
306
|
+
* place, in the same order and over the same connection as notifications,
|
|
307
|
+
* never beside a long poll. Absent, a row is answered and sent in one go.
|
|
302
308
|
*/
|
|
303
309
|
export async function processTelegramConversations(args) {
|
|
304
310
|
const limit = args.limit ?? 20;
|
|
305
311
|
for (let count = 0; count < limit && args.signal?.aborted !== true; count++) {
|
|
306
|
-
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock());
|
|
312
|
+
const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock(), args.only);
|
|
307
313
|
if (row === null)
|
|
308
314
|
return;
|
|
309
315
|
try {
|
|
@@ -318,6 +324,8 @@ export async function processTelegramConversations(args) {
|
|
|
318
324
|
}
|
|
319
325
|
class PlanRefused extends Error {
|
|
320
326
|
}
|
|
327
|
+
/** The outcome a planned reply waits under until the bridge's cycle sends it. */
|
|
328
|
+
const ANSWERED_SENDING = "answered:sending";
|
|
321
329
|
/**
|
|
322
330
|
* One claimed message, in order of what is already known about it:
|
|
323
331
|
*
|
|
@@ -334,12 +342,13 @@ class PlanRefused extends Error {
|
|
|
334
342
|
* the ceiling the turn opened under; only a confirmed message id counts.
|
|
335
343
|
*/
|
|
336
344
|
async function runTelegramConversation(row, args) {
|
|
337
|
-
const { store, botId, transport, owner, clock, readProjects, options, report } = args;
|
|
345
|
+
const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
|
|
338
346
|
const finish = (result) => store.finishTelegramConversation(row.id, owner, result, clock());
|
|
339
347
|
const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
|
|
340
348
|
const ageMs = clock().getTime() - Date.parse(row.createdAt);
|
|
341
|
-
const requeue = (outcome) => {
|
|
349
|
+
const requeue = async (outcome) => {
|
|
342
350
|
if (ageMs > CONVERSATION_MAX_AGE_MS) {
|
|
351
|
+
await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
|
|
343
352
|
finish({ state: "failed", outcome: `${outcome}:gave-up` });
|
|
344
353
|
report.refused++;
|
|
345
354
|
return;
|
|
@@ -384,6 +393,7 @@ async function runTelegramConversation(row, args) {
|
|
|
384
393
|
const bound = store.getTelegramConversation(row.id)?.session ?? null;
|
|
385
394
|
const session = bound === null ? null : store.getMateSession(bound);
|
|
386
395
|
if (session === null) {
|
|
396
|
+
await notify(couldNotAnswerText("the chat session for this reply is gone"));
|
|
387
397
|
finish({ state: "failed", outcome: "unsent:no-session" });
|
|
388
398
|
report.refused++;
|
|
389
399
|
return;
|
|
@@ -407,6 +417,7 @@ async function runTelegramConversation(row, args) {
|
|
|
407
417
|
}
|
|
408
418
|
if (now.getTime() - Date.parse(row.createdAt) > DELIVERY_MAX_AGE_MS) {
|
|
409
419
|
report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
|
|
420
|
+
await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
|
|
410
421
|
finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
|
|
411
422
|
report.refused++;
|
|
412
423
|
return;
|
|
@@ -512,15 +523,24 @@ async function runTelegramConversation(row, args) {
|
|
|
512
523
|
...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
|
|
513
524
|
});
|
|
514
525
|
}
|
|
526
|
+
// The first time the answer itself fails to go out, the person hears so once,
|
|
527
|
+
// in plain words; the reply itself keeps being retried and shows as
|
|
528
|
+
// unsent in status and the console after two minutes.
|
|
529
|
+
const firstFailure = part.kind === "reply" && store.listTelegramConversationParts(row.id).every(one => one.attempts <= (one.state === "sent" ? 1 : 0));
|
|
530
|
+
const failed = async (until, error, uncertain) => {
|
|
531
|
+
if (!store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain, retryAt: until.toISOString() }, clock()))
|
|
532
|
+
return;
|
|
533
|
+
if (firstFailure)
|
|
534
|
+
await notify(couldNotAnswerText(uncertain ? "my reply may not have reached Telegram" : "Telegram didn't accept my reply"));
|
|
535
|
+
defer(until, error);
|
|
536
|
+
};
|
|
515
537
|
let answer;
|
|
516
538
|
try {
|
|
517
539
|
answer = await send();
|
|
518
540
|
}
|
|
519
541
|
catch {
|
|
520
542
|
// The answer was lost: Telegram may or may not have the message. Said so, retried, counted.
|
|
521
|
-
|
|
522
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
523
|
-
defer(backoff, error);
|
|
543
|
+
await failed(backoff, "Telegram transport failed; delivery may be uncertain", true);
|
|
524
544
|
return;
|
|
525
545
|
}
|
|
526
546
|
if (!answer.ok) {
|
|
@@ -535,15 +555,12 @@ async function runTelegramConversation(row, args) {
|
|
|
535
555
|
if (paused > until)
|
|
536
556
|
until = paused;
|
|
537
557
|
}
|
|
538
|
-
|
|
539
|
-
defer(until, error);
|
|
558
|
+
await failed(until, error, uncertain);
|
|
540
559
|
return;
|
|
541
560
|
}
|
|
542
561
|
const id = answer.result?.message_id;
|
|
543
562
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
544
|
-
|
|
545
|
-
if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
|
|
546
|
-
defer(backoff, error);
|
|
563
|
+
await failed(backoff, "Telegram returned no confirmed message identity", true);
|
|
547
564
|
return;
|
|
548
565
|
}
|
|
549
566
|
const messageId = String(id);
|
|
@@ -596,11 +613,11 @@ async function runTelegramConversation(row, args) {
|
|
|
596
613
|
}
|
|
597
614
|
store.bindTelegramConversationTurn(row.id, owner, session, turnId);
|
|
598
615
|
if (turn.state === "queued" || turn.state === "running") {
|
|
599
|
-
requeue("replayed:running");
|
|
616
|
+
await requeue("replayed:running");
|
|
600
617
|
return;
|
|
601
618
|
}
|
|
602
619
|
if (turn.state !== "answered") {
|
|
603
|
-
await notify(`
|
|
620
|
+
await notify(couldNotAnswerText(`the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)})`));
|
|
604
621
|
finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
|
|
605
622
|
report.refused++;
|
|
606
623
|
return;
|
|
@@ -609,11 +626,19 @@ async function runTelegramConversation(row, args) {
|
|
|
609
626
|
const proposals = store.listMateProposals(turn.thread, ["pending"]).filter(one => one.turn === turn.id);
|
|
610
627
|
if (!plan(session, turnId, reply, proposals, repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
611
628
|
return;
|
|
612
|
-
await
|
|
629
|
+
await sendOrHandOff("replayed");
|
|
630
|
+
};
|
|
631
|
+
/** Send now, or — for the follower's turn worker — leave it to the bridge's cycle, which sends it next. */
|
|
632
|
+
const sendOrHandOff = async (outcomeWord) => {
|
|
633
|
+
if (only !== "turns") {
|
|
634
|
+
await deliver(outcomeWord);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
finish({ state: "queued", outcome: outcomeWord === "answered" ? ANSWERED_SENDING : "delivering", nextAttemptAt: clock().toISOString() });
|
|
613
638
|
};
|
|
614
639
|
// 2. Parts already planned: the turn answered; only the sending remains.
|
|
615
640
|
if (store.listTelegramConversationParts(row.id).length > 0) {
|
|
616
|
-
await deliver("replayed");
|
|
641
|
+
await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
|
|
617
642
|
return;
|
|
618
643
|
}
|
|
619
644
|
// 3. The ceiling, reloaded now.
|
|
@@ -623,7 +648,7 @@ async function runTelegramConversation(row, args) {
|
|
|
623
648
|
}
|
|
624
649
|
catch {
|
|
625
650
|
report.problems.push("telegram chat could not read the current project records");
|
|
626
|
-
requeue("registry-unavailable");
|
|
651
|
+
await requeue("registry-unavailable");
|
|
627
652
|
return;
|
|
628
653
|
}
|
|
629
654
|
const repos = telegramConversationRepos(store, binding.approver, registry);
|
|
@@ -638,6 +663,10 @@ async function runTelegramConversation(row, args) {
|
|
|
638
663
|
}
|
|
639
664
|
// 5. The session and thread — shared with the console and the CLI.
|
|
640
665
|
const resolved = resolveTelegramMate(store, binding, repos, clock());
|
|
666
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
667
|
+
await requeue("busy");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
641
670
|
if (!resolved.ok) {
|
|
642
671
|
if (resolved.said !== null)
|
|
643
672
|
await notify(resolved.said);
|
|
@@ -676,10 +705,10 @@ async function runTelegramConversation(row, args) {
|
|
|
676
705
|
store.bindTelegramConversationTurn(row.id, owner, session.id, receipt.turn);
|
|
677
706
|
if (!outcome.ok && "refused" in outcome) {
|
|
678
707
|
if (outcome.refused === "concurrent") {
|
|
679
|
-
requeue("busy");
|
|
708
|
+
await requeue("busy");
|
|
680
709
|
return;
|
|
681
710
|
}
|
|
682
|
-
await notify(
|
|
711
|
+
await notify(phoneText(couldNotAnswerText(outcome.message), 1_000));
|
|
683
712
|
finish({ state: "failed", outcome: `refused:${outcome.refused}` });
|
|
684
713
|
report.refused++;
|
|
685
714
|
return;
|
|
@@ -687,7 +716,7 @@ async function runTelegramConversation(row, args) {
|
|
|
687
716
|
if (!outcome.ok) {
|
|
688
717
|
// Truthful: what failed, that nothing was kept, and that a new message
|
|
689
718
|
// is a new turn. A failed turn's drafts are already gone.
|
|
690
|
-
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : `
|
|
719
|
+
const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : couldNotAnswerText(`the assistant's reply did not complete (${outcome.message})`);
|
|
691
720
|
await notify(phoneText(words, 1_000));
|
|
692
721
|
finish({ state: "failed", outcome: `failed:${outcome.failed}` });
|
|
693
722
|
report.refused++;
|
|
@@ -703,5 +732,5 @@ async function runTelegramConversation(row, args) {
|
|
|
703
732
|
const proposals = store.listMateProposals(thread.id, ["pending"]).filter(one => one.turn === outcome.turn);
|
|
704
733
|
if (!plan(session.id, outcome.turn, outcome.reply, proposals, who.repos) && store.listTelegramConversationParts(row.id).length === 0)
|
|
705
734
|
return;
|
|
706
|
-
await
|
|
735
|
+
await sendOrHandOff("answered");
|
|
707
736
|
}
|
|
@@ -8,6 +8,7 @@ import { assignmentOf } from "./assignment.js";
|
|
|
8
8
|
import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { leadClaimOf } from "./lead-voice.js";
|
|
11
12
|
import { chatTitle } from "./chat-voice.js";
|
|
12
13
|
import { manualReviewOnly } from "./proof.js";
|
|
13
14
|
import { failedCheckExit } from "./workspace-ui.js";
|
|
@@ -78,6 +79,12 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
78
79
|
const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
|
|
79
80
|
if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
|
|
80
81
|
facts = { ...facts, completedBy: null };
|
|
82
|
+
// The person's lead took it on: "Your lead is on it" rather than waiting for them (lead-voice.ts).
|
|
83
|
+
if (facts.lead === undefined) {
|
|
84
|
+
const lead = leadClaimOf(store, taskId, now, viewer);
|
|
85
|
+
if (lead !== null)
|
|
86
|
+
facts = { ...facts, lead: lead.state };
|
|
87
|
+
}
|
|
81
88
|
const status = taskStatusOf(facts);
|
|
82
89
|
const checkProgress = store.checkProgress(run.id);
|
|
83
90
|
const progress = status.headline === "Building" && checkProgress !== null ? [`● ${checkProgress.line}`] : [];
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* the settings page; this card only mints or revokes the signed-in person's
|
|
4
4
|
* pairing, never anyone else's. */
|
|
5
5
|
import type { Store } from "./store.js";
|
|
6
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
7
|
+
export declare const UNSENT_REPLY_MS: number;
|
|
6
8
|
export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, csrf: string, options?: {
|
|
7
9
|
code?: string;
|
|
8
10
|
problem?: string;
|
|
11
|
+
now?: Date;
|
|
9
12
|
}): string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
2
|
+
export const UNSENT_REPLY_MS = 2 * 60_000;
|
|
1
3
|
const escape = (text) => text
|
|
2
4
|
.replaceAll("&", "&")
|
|
3
5
|
.replaceAll("<", "<")
|
|
@@ -17,6 +19,15 @@ export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
|
|
|
17
19
|
const mine = bindings.filter(binding => binding.approver === who);
|
|
18
20
|
const others = bindings.length - mine.length;
|
|
19
21
|
content = `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`;
|
|
22
|
+
// A reply the assistant wrote that has not reached this person's phone for two minutes.
|
|
23
|
+
const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
|
|
24
|
+
if (unsent.length > 0) {
|
|
25
|
+
const oldest = unsent[0];
|
|
26
|
+
content +=
|
|
27
|
+
`<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
|
|
28
|
+
`<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
|
|
29
|
+
`<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
|
|
30
|
+
}
|
|
20
31
|
if (options.code !== undefined) {
|
|
21
32
|
content +=
|
|
22
33
|
"<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
|
package/dist/telegram.d.ts
CHANGED
|
@@ -98,6 +98,20 @@ export type TelegramUpload = {
|
|
|
98
98
|
bytes: Buffer;
|
|
99
99
|
};
|
|
100
100
|
export declare function createTransport(token: string, timeoutMs?: number): TelegramTransport;
|
|
101
|
+
/**
|
|
102
|
+
* Why this process can't reach Telegram the way the service does, or null.
|
|
103
|
+
* A coding agent's sandbox fences the network: Codex says so in its own
|
|
104
|
+
* variables, and Claude Code's sandbox routes traffic through a proxy on
|
|
105
|
+
* this computer, which curl uses and Node's fetch does not — so sends fail
|
|
106
|
+
* "fetch failed" while curl works (Oct 2). Builds, checks and agents also
|
|
107
|
+
* get a database of their own, so none of them can hold the live bridge;
|
|
108
|
+
* this keeps an agent-launched `watch` or `bridge` from holding it either.
|
|
109
|
+
*/
|
|
110
|
+
export declare function networkFence(env: Record<string, string | undefined>): string | null;
|
|
111
|
+
/** The plain line a fenced process says instead of polling or sending. */
|
|
112
|
+
export declare function networkFenceLine(reason: string): string;
|
|
113
|
+
/** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
|
|
114
|
+
export declare function transportError(error: unknown): string;
|
|
101
115
|
/** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
|
|
102
116
|
export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
|
|
103
117
|
export declare function hashPairingCode(code: string): string;
|