slack-term 1.51.2 → 1.52.0
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/README.md +10 -0
- package/dist/cli.js +231 -229
- package/package.json +1 -1
- package/ts/askPush.ts +83 -0
- package/ts/botIntent.ts +5 -4
- package/ts/cli.ts +163 -11
- package/ts/sentlog.ts +16 -2
package/package.json
CHANGED
package/ts/askPush.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `slack ask --pending --push`: hand an answered question to the agent that
|
|
2
|
+
// asked it, instead of waiting for that agent to poll.
|
|
3
|
+
//
|
|
4
|
+
// Why: a pill pressed while the asker had moved on was never read back
|
|
5
|
+
// (2026-09-27: 13 of 71 asks). `--pending` lists them, but only to whoever
|
|
6
|
+
// runs it; a wrapper outside slack-term pushed them over `ay`, and that now
|
|
7
|
+
// lives here instead. The sent-log already holds the asking
|
|
8
|
+
// agent's pid, so slack-term can tell it directly.
|
|
9
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
10
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { pidAlive } from "./sentlog.ts";
|
|
13
|
+
|
|
14
|
+
/** ay rejects messages over 1024 chars. */
|
|
15
|
+
export const PUSH_MAX = 1000;
|
|
16
|
+
|
|
17
|
+
function clip(s: string, n: number): string {
|
|
18
|
+
const one = s.split(/\s+/).filter(Boolean).join(" ");
|
|
19
|
+
if (n <= 1) return "";
|
|
20
|
+
return one.length <= n ? one : `${one.slice(0, n - 1)}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The one-line message the asker receives: link, answer, how, question. */
|
|
24
|
+
export function askPushMessage(p: { link: string; answer: string; how: string; question: string; freeText: boolean; collected?: boolean }): string {
|
|
25
|
+
const head = `[ask-answer] ${p.link} →`;
|
|
26
|
+
const ans = clip(p.answer, 300);
|
|
27
|
+
let tail = p.how ? ` (${p.how})` : "";
|
|
28
|
+
if (p.freeText) tail += " — a free-text reply, not a choice: the question stays open";
|
|
29
|
+
else if (p.collected) tail += " — collected ✅";
|
|
30
|
+
const q = clip(p.question, PUSH_MAX - head.length - ans.length - tail.length - 8);
|
|
31
|
+
return `${head} ${ans}${tail}. Q: ${q}`.slice(0, PUSH_MAX);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Run = (cmd: string, args: string[]) => Pick<SpawnSyncReturns<string>, "status" | "stdout" | "stderr" | "error">;
|
|
35
|
+
const runSync: Run = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: 60_000 });
|
|
36
|
+
|
|
37
|
+
/** Parent pid, or 0. */
|
|
38
|
+
export function parentPid(pid: number): number {
|
|
39
|
+
try {
|
|
40
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
41
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]) || 0;
|
|
42
|
+
} catch {
|
|
43
|
+
const r = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
|
|
44
|
+
return Number((r.stdout ?? "").trim()) || 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Send `msg` to the agent at `pid` with `ay send --force`. The sent-log keeps
|
|
49
|
+
* the agent's own pid, while ay may know the wrapper above it, so up to three
|
|
50
|
+
* parents are tried too. Returns the pid that took it, or undefined. QUEUED
|
|
51
|
+
* and NOT SUBMITTED count as delivered: both reach the agent, and treating
|
|
52
|
+
* them as failures re-sent the same answer on every pass. */
|
|
53
|
+
export function notifyAgent(pid: number, msg: string, run: Run = runSync, parent = parentPid): number | undefined {
|
|
54
|
+
for (let i = 0; i < 4 && pid > 1; i++, pid = parent(pid)) {
|
|
55
|
+
const r = run(process.env.SLACK_TERM_AY || "ay", ["send", String(pid), "--force", msg]);
|
|
56
|
+
if (r.error) return undefined; // no ay on this machine
|
|
57
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
58
|
+
if (/no agent matched/i.test(out)) continue;
|
|
59
|
+
if (r.status === 0 || /QUEUED|sent to pid|NOT SUBMITTED/.test(out)) return pid;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One push pass at a time per machine: a watcher and a one-off run must not
|
|
65
|
+
* both push the same answer. Returns a release function, or undefined when a
|
|
66
|
+
* live pass holds the lock (a dead holder's lock is taken over). */
|
|
67
|
+
export function acquirePushLock(path: string): (() => void) | undefined {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const fd = openSync(path, "wx");
|
|
72
|
+
writeFileSync(fd, String(process.pid));
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
return () => { try { unlinkSync(path); } catch { /* gone */ } };
|
|
75
|
+
} catch {
|
|
76
|
+
let holder = 0;
|
|
77
|
+
try { holder = Number(readFileSync(path, "utf8").trim()); } catch { /* vanished */ }
|
|
78
|
+
if (holder && holder !== process.pid && pidAlive(holder)) return undefined;
|
|
79
|
+
try { unlinkSync(path); } catch { /* raced */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
package/ts/botIntent.ts
CHANGED
|
@@ -30,11 +30,12 @@ export function isWriteCommand(path: (string | number)[]): boolean {
|
|
|
30
30
|
return WRITES.has(a) || WRITES.has(`${a} ${b}`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Read-only modes of a write command. `ask --ls`
|
|
34
|
-
* sent-log and the questions' live state. (`ask --waitFor
|
|
35
|
-
* `poll --results` are NOT here:
|
|
33
|
+
/** Read-only modes of a write command. `ask --ls` and `ask --pending` only
|
|
34
|
+
* read the local sent-log and the questions' live state. (`ask --waitFor`,
|
|
35
|
+
* `ask --pending --push` and `poll --results` are NOT here: each can edit the
|
|
36
|
+
* posted message.) */
|
|
36
37
|
export function isReadMode(path: (string | number)[], argv: Record<string, unknown>): boolean {
|
|
37
|
-
return String(path[0] ?? "") === "ask" && argv.ls === true;
|
|
38
|
+
return String(path[0] ?? "") === "ask" && (argv.ls === true || (argv.pending === true && argv.push !== true));
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export type BotIntent = {
|
package/ts/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { existsSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
|
|
@@ -13,8 +14,9 @@ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveTok
|
|
|
13
14
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
15
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
16
|
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
|
-
import { runStream, webClient, type StreamMatch } from "./stream.ts";
|
|
17
|
+
import { phiDelay, runStream, webClient, type StreamMatch } from "./stream.ts";
|
|
17
18
|
import { relaySubscriber, relayTransport } from "./relay.ts";
|
|
19
|
+
import { acquirePushLock, askPushMessage, notifyAgent } from "./askPush.ts";
|
|
18
20
|
import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
|
|
19
21
|
import { agentCommands } from "./agent.ts";
|
|
20
22
|
|
|
@@ -70,7 +72,7 @@ import {
|
|
|
70
72
|
} from "./poll.ts";
|
|
71
73
|
import { seedReactionsInOrder } from "./reactionSeed.ts";
|
|
72
74
|
import { buildListSchema, cellValue, completedColumn, findColumn, parseColumnSpec, parseListSchema, parseSets, type ListColumn } from "./lists.ts";
|
|
73
|
-
import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
75
|
+
import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, markAskPushed, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
74
76
|
import {
|
|
75
77
|
authTest,
|
|
76
78
|
authScopes,
|
|
@@ -4247,6 +4249,10 @@ interface AskPendingItem {
|
|
|
4247
4249
|
alive: boolean;
|
|
4248
4250
|
/** The ready-to-run `--waitFor` line, cursor included. */
|
|
4249
4251
|
collect: string;
|
|
4252
|
+
/** Where a delivery of this free-text reply ends (`--after` for the next look). */
|
|
4253
|
+
cursor?: string;
|
|
4254
|
+
/** Set by `--push`: the pid that took the answer, and whether it was collected. */
|
|
4255
|
+
pushed?: { pid: number; collected: boolean };
|
|
4250
4256
|
}
|
|
4251
4257
|
|
|
4252
4258
|
/** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
|
|
@@ -4265,16 +4271,24 @@ interface AskPendingItem {
|
|
|
4265
4271
|
* a person) runs the collect line. */
|
|
4266
4272
|
async function cmdAskPending(
|
|
4267
4273
|
tokens: { user?: string; bot?: string; cookie?: string },
|
|
4268
|
-
opts: {
|
|
4274
|
+
opts: {
|
|
4275
|
+
sinceMs: number; json: boolean; channel?: string; push?: boolean; selfArgs?: string[];
|
|
4276
|
+
/** `--watch`: only these questions (`channel:ts`), and print only what was pushed. */
|
|
4277
|
+
only?: Set<string>; watch?: boolean;
|
|
4278
|
+
},
|
|
4269
4279
|
): Promise<void> {
|
|
4280
|
+
// Never blocks: one look per question and no waiting out 429s. A throttled
|
|
4281
|
+
// pass says how many it skipped; the next pass picks them up.
|
|
4282
|
+
setRateLimitWait(0);
|
|
4270
4283
|
const all = uncollectedAsks(opts.sinceMs);
|
|
4271
4284
|
if (!all) {
|
|
4272
4285
|
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
4273
4286
|
process.exit(ASK_EXIT_ERROR);
|
|
4274
4287
|
}
|
|
4275
|
-
const rows =
|
|
4288
|
+
const rows = all.filter((r) =>
|
|
4289
|
+
(!opts.channel || r.channel === opts.channel || r.target === opts.channel) && (!opts.only || opts.only.has(`${r.channel}:${r.ts}`)));
|
|
4276
4290
|
const pending: AskPendingItem[] = [];
|
|
4277
|
-
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
|
|
4291
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0, throttled: 0 };
|
|
4278
4292
|
/** Open questions someone pressed ℹ️ on — reported only: the thread note is
|
|
4279
4293
|
* the waiting session's to post (`--pending` writes nothing). */
|
|
4280
4294
|
const explainAsked: string[] = [];
|
|
@@ -4287,7 +4301,7 @@ async function cmdAskPending(
|
|
|
4287
4301
|
: tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
|
|
4288
4302
|
const otherTeams = new Map<string, number>();
|
|
4289
4303
|
|
|
4290
|
-
for (const row of rows) {
|
|
4304
|
+
for (const [i, row] of rows.entries()) {
|
|
4291
4305
|
if (row.team && currentTeam && row.team !== currentTeam) {
|
|
4292
4306
|
otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
|
|
4293
4307
|
continue;
|
|
@@ -4330,19 +4344,30 @@ async function cmdAskPending(
|
|
|
4330
4344
|
if (found) {
|
|
4331
4345
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
4332
4346
|
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
4333
|
-
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
|
|
4347
|
+
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true, cursor: askNextCursor(row.ts, after, unchosen.ts) });
|
|
4334
4348
|
} else if (unchosen?.ambiguous) {
|
|
4335
4349
|
count.ambiguous++;
|
|
4336
4350
|
} else {
|
|
4337
4351
|
count.unanswered++;
|
|
4338
4352
|
}
|
|
4339
4353
|
} catch (e: unknown) {
|
|
4354
|
+
if (e instanceof RateLimitError) {
|
|
4355
|
+
// Probing on would only collect more 429s for the same method.
|
|
4356
|
+
count.throttled = rows.length - i;
|
|
4357
|
+
problems.push(`rate-limited by Slack (${e.message}): ${count.throttled} question(s) not checked this pass`);
|
|
4358
|
+
break;
|
|
4359
|
+
}
|
|
4340
4360
|
problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4341
4361
|
}
|
|
4342
4362
|
}
|
|
4343
4363
|
|
|
4364
|
+
if (opts.push) askPushAll(pending, opts.selfArgs ?? [], problems);
|
|
4365
|
+
|
|
4366
|
+
// A watcher's log carries what it did, not the whole list on every pass.
|
|
4367
|
+
const shown = opts.watch ? pending.filter((it) => it.pushed) : pending;
|
|
4368
|
+
if (opts.watch && !shown.length && !problems.length) return;
|
|
4344
4369
|
if (opts.json) {
|
|
4345
|
-
for (const it of
|
|
4370
|
+
for (const it of shown) {
|
|
4346
4371
|
const r = it.row;
|
|
4347
4372
|
console.log(JSON.stringify({
|
|
4348
4373
|
id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
|
|
@@ -4350,12 +4375,13 @@ async function cmdAskPending(
|
|
|
4350
4375
|
sender: { session_id: r.session_id ?? null, cli: r.cli ?? null, agent_pid: r.agent_pid ?? null, pid: r.pid, alive: it.alive, cwd: r.cwd, git_branch: r.git_branch ?? null, host: r.host ?? null },
|
|
4351
4376
|
delivered_at: r.delivered_at ?? null,
|
|
4352
4377
|
collect: it.collect,
|
|
4378
|
+
...(opts.push ? { pushed: it.pushed ?? null } : {}),
|
|
4353
4379
|
}));
|
|
4354
4380
|
}
|
|
4355
4381
|
} else {
|
|
4356
4382
|
// Grouped by the session that asked — the unit that has to be told.
|
|
4357
4383
|
const groups = new Map<string, AskPendingItem[]>();
|
|
4358
|
-
for (const it of
|
|
4384
|
+
for (const it of shown) {
|
|
4359
4385
|
const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
|
|
4360
4386
|
groups.set(key, [...(groups.get(key) ?? []), it]);
|
|
4361
4387
|
}
|
|
@@ -4370,6 +4396,7 @@ async function cmdAskPending(
|
|
|
4370
4396
|
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
4371
4397
|
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — not a decision" : ""}`);
|
|
4372
4398
|
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
4399
|
+
if (it.pushed) console.log(` pushed to pid ${it.pushed.pid}${it.pushed.collected ? ", collected ✅" : ""}`);
|
|
4373
4400
|
}
|
|
4374
4401
|
}
|
|
4375
4402
|
}
|
|
@@ -4384,10 +4411,113 @@ async function cmdAskPending(
|
|
|
4384
4411
|
(count.resolved ? `, ${count.resolved} ✅ already collected` : "") +
|
|
4385
4412
|
(count.void ? `, ${count.void} voided` : "") +
|
|
4386
4413
|
(count.explain ? `, ${count.explain} with an ℹ️ explain request` : "") +
|
|
4414
|
+
(count.throttled ? `, ${count.throttled} not checked (rate-limited)` : "") +
|
|
4415
|
+
(opts.push ? `, ${pending.filter((it) => it.pushed).length} pushed to the asker` : "") +
|
|
4387
4416
|
` — checked ${rows.length} uncollected question(s) (log: ${sentDbPath()})`,
|
|
4388
4417
|
);
|
|
4389
4418
|
}
|
|
4390
4419
|
|
|
4420
|
+
/** `ask --pending --push --watch`: a push pass every `intervalMs`, and — when
|
|
4421
|
+
* SLACK_RELAY_URL / SLACK_RELAY_TOKEN are set — one for a single question as
|
|
4422
|
+
* soon as the relay rings for it (a reaction on it, or a reply in its
|
|
4423
|
+
* thread), so a pressed pill reaches the asker in seconds. The relay only
|
|
4424
|
+
* sees the relay app's channels; the interval covers the rest. */
|
|
4425
|
+
async function askPushWatch(run: (only?: Set<string>) => Promise<void>, sinceMs: () => number, intervalMs: number): Promise<never> {
|
|
4426
|
+
let busy = false;
|
|
4427
|
+
const queued = new Set<string>();
|
|
4428
|
+
let full = true;
|
|
4429
|
+
const kick = async (): Promise<void> => {
|
|
4430
|
+
if (busy) return;
|
|
4431
|
+
busy = true;
|
|
4432
|
+
try {
|
|
4433
|
+
while (full || queued.size) {
|
|
4434
|
+
const only = full ? undefined : new Set(queued);
|
|
4435
|
+
full = false;
|
|
4436
|
+
queued.clear();
|
|
4437
|
+
try {
|
|
4438
|
+
await run(only);
|
|
4439
|
+
} catch (e: unknown) {
|
|
4440
|
+
console.error(`slack ask --watch: pass failed: ${friendlySlackError(e)}`);
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
} finally {
|
|
4444
|
+
busy = false;
|
|
4445
|
+
}
|
|
4446
|
+
};
|
|
4447
|
+
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
4448
|
+
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
4449
|
+
if (relayUrl && relayToken) {
|
|
4450
|
+
const subscribe = relaySubscriber(relayUrl, relayToken, undefined, { transport: relayTransport(process.env.SLACK_RELAY_TRANSPORT) });
|
|
4451
|
+
void (async () => {
|
|
4452
|
+
for (let attempt = 0; ; attempt++) {
|
|
4453
|
+
try {
|
|
4454
|
+
await subscribe(undefined, new AbortController().signal, {
|
|
4455
|
+
hello: () => { attempt = 0; },
|
|
4456
|
+
bell: (_seq, b) => {
|
|
4457
|
+
const key = `${b.channel}:${b.thread_ts ?? b.ts}`;
|
|
4458
|
+
const open = uncollectedAsks(sinceMs()) ?? [];
|
|
4459
|
+
if (!open.some((r) => `${r.channel}:${r.ts}` === key)) return;
|
|
4460
|
+
queued.add(key);
|
|
4461
|
+
// A few seconds' grace: a pill press often comes with a reply.
|
|
4462
|
+
setTimeout(() => { void kick(); }, 3000);
|
|
4463
|
+
},
|
|
4464
|
+
});
|
|
4465
|
+
} catch (e: unknown) {
|
|
4466
|
+
console.error(`slack ask --watch: relay: ${e instanceof Error ? e.message : String(e)} — reconnecting`);
|
|
4467
|
+
}
|
|
4468
|
+
await new Promise((r) => setTimeout(r, phiDelay(attempt, 2000, 300_000)));
|
|
4469
|
+
}
|
|
4470
|
+
})();
|
|
4471
|
+
} else {
|
|
4472
|
+
console.error("slack ask --watch: no SLACK_RELAY_URL / SLACK_RELAY_TOKEN — checking every interval only");
|
|
4473
|
+
}
|
|
4474
|
+
for (;;) {
|
|
4475
|
+
full = true;
|
|
4476
|
+
await kick();
|
|
4477
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
/** `--push`: tell each alive asker its answer over ay, then collect a decision
|
|
4482
|
+
* (✅ in Slack, collected in the log) with the asker's own `--waitFor` line —
|
|
4483
|
+
* the same code path, so the stamp and the bookkeeping cannot drift. A
|
|
4484
|
+
* free-text reply is marked delivered instead: it is not a decision, so the
|
|
4485
|
+
* question stays open, but that reply is not pushed again. An asker that is
|
|
4486
|
+
* gone gets nothing: the answer stays on the list for a person. */
|
|
4487
|
+
function askPushAll(items: AskPendingItem[], selfArgs: string[], problems: string[]): void {
|
|
4488
|
+
const release = acquirePushLock(join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "slack-term", "ask-push.lock"));
|
|
4489
|
+
if (!release) {
|
|
4490
|
+
problems.push("another `ask --pending --push` pass is running: nothing pushed by this one");
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
try {
|
|
4494
|
+
for (const it of items) {
|
|
4495
|
+
const r = it.row;
|
|
4496
|
+
if (!it.alive || r.pushed_answer === it.answer) continue;
|
|
4497
|
+
const msg = askPushMessage({ link: it.link, answer: it.answer, how: it.how, question: it.question, freeText: it.freeText, collected: !it.freeText });
|
|
4498
|
+
const pid = notifyAgent(r.agent_pid ?? r.pid, msg);
|
|
4499
|
+
if (pid === undefined) {
|
|
4500
|
+
problems.push(`${it.link}: the asker (pid ${r.agent_pid ?? r.pid}) did not take the push over ay — tried again next pass`);
|
|
4501
|
+
continue;
|
|
4502
|
+
}
|
|
4503
|
+
markAskPushed(r.channel, r.ts, it.answer);
|
|
4504
|
+
let collected = false;
|
|
4505
|
+
if (it.freeText) {
|
|
4506
|
+
markAskDelivered(r.channel, r.ts, it.answer, it.cursor ?? "");
|
|
4507
|
+
} else {
|
|
4508
|
+
const after = r.delivered_cursor ? [`--after=${r.delivered_cursor}`] : [];
|
|
4509
|
+
const c = spawnSync(process.execPath, [...process.argv.slice(1, 2), ...selfArgs, "ask", `--waitFor=${it.link}`, "--timeout", "0", ...(r.as_bot ? ["--as-bot"] : []), ...after],
|
|
4510
|
+
{ encoding: "utf8", timeout: 120_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
4511
|
+
collected = c.status === 0;
|
|
4512
|
+
if (!collected) problems.push(`${it.link}: pushed, but collecting it exited ${c.status ?? "on a signal"} — collect by hand: ${it.collect}`);
|
|
4513
|
+
}
|
|
4514
|
+
it.pushed = { pid, collected };
|
|
4515
|
+
}
|
|
4516
|
+
} finally {
|
|
4517
|
+
release();
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4391
4521
|
/** A question about to be voided: where it is, and what it parsed as. */
|
|
4392
4522
|
interface AskVoidTarget {
|
|
4393
4523
|
link: string;
|
|
@@ -6842,7 +6972,10 @@ async function main(): Promise<void> {
|
|
|
6842
6972
|
.option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it — one gate for both. The way to change the options of a question someone already answered." })
|
|
6843
6973
|
.option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choices…]] [--body …]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
|
|
6844
6974
|
.option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
|
|
6845
|
-
.option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only: --since 7d, --channel, --json." })
|
|
6975
|
+
.option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only unless --push: --since 7d, --channel, --json. Never waits out a rate limit: a throttled pass says how many it skipped." })
|
|
6976
|
+
.option("push", { type: "boolean", default: false, describe: "With --pending: send each answer to the agent that asked (ay send to the pid in the sent-log, or up to 3 of its parents), then collect a decision (✅, off this list); a free-text reply is marked delivered, the question stays open. Askers that are gone get nothing. Each answer is pushed once." })
|
|
6977
|
+
.option("watch", { type: "boolean", default: false, describe: "With --pending --push: keep running — a pass every --interval, and one for a question as soon as the relay rings for it (SLACK_RELAY_URL / SLACK_RELAY_TOKEN). Prints only what it pushed." })
|
|
6978
|
+
.option("interval", { type: "number", default: 10, describe: "With --watch: minutes between full passes" })
|
|
6846
6979
|
.option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
|
|
6847
6980
|
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
6848
6981
|
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
@@ -6886,8 +7019,27 @@ async function main(): Promise<void> {
|
|
|
6886
7019
|
console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
|
|
6887
7020
|
process.exit(ASK_EXIT_ERROR);
|
|
6888
7021
|
}
|
|
6889
|
-
|
|
7022
|
+
if (argv.watch && !argv.push) {
|
|
7023
|
+
console.error("Error: --watch keeps pushing answers — use it with --push.");
|
|
7024
|
+
process.exit(ASK_EXIT_ERROR);
|
|
7025
|
+
}
|
|
7026
|
+
const p: Parameters<typeof cmdAskPending>[1] = { sinceMs, json: !!argv.json, push: !!argv.push };
|
|
6890
7027
|
if (argv.channel) p.channel = String(argv.channel);
|
|
7028
|
+
// The collect run must read with the same workspace this one does.
|
|
7029
|
+
if (argv.workspace) p.selfArgs = ["-w", String(argv.workspace)];
|
|
7030
|
+
if (argv.watch) {
|
|
7031
|
+
const min = Number(argv.interval);
|
|
7032
|
+
if (!(min > 0)) {
|
|
7033
|
+
console.error("Error: --interval needs a number of minutes above 0.");
|
|
7034
|
+
process.exit(ASK_EXIT_ERROR);
|
|
7035
|
+
}
|
|
7036
|
+
const since = String(argv.since);
|
|
7037
|
+
await askPushWatch(
|
|
7038
|
+
(only) => cmdAskPending(tokens, { ...p, sinceMs: parseSince(since) ?? sinceMs, watch: true, ...(only ? { only } : {}) }),
|
|
7039
|
+
() => parseSince(since) ?? sinceMs,
|
|
7040
|
+
min * 60_000,
|
|
7041
|
+
);
|
|
7042
|
+
}
|
|
6891
7043
|
try {
|
|
6892
7044
|
await cmdAskPending(tokens, p);
|
|
6893
7045
|
} catch (e: unknown) {
|
package/ts/sentlog.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface SentRow {
|
|
|
70
70
|
delivered_at?: number | null;
|
|
71
71
|
delivered_answer?: string | null;
|
|
72
72
|
delivered_cursor?: string | null;
|
|
73
|
+
/** `ask --pending --push` handed this answer to the asking agent (over ay):
|
|
74
|
+
* the same answer is not pushed twice. */
|
|
75
|
+
pushed_at?: number | null;
|
|
76
|
+
pushed_answer?: string | null;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export function attributionEnabled(): boolean {
|
|
@@ -239,7 +243,9 @@ CREATE TABLE IF NOT EXISTS sent (
|
|
|
239
243
|
answer_exit INTEGER,
|
|
240
244
|
delivered_at INTEGER,
|
|
241
245
|
delivered_answer TEXT,
|
|
242
|
-
delivered_cursor TEXT
|
|
246
|
+
delivered_cursor TEXT,
|
|
247
|
+
pushed_at INTEGER,
|
|
248
|
+
pushed_answer TEXT
|
|
243
249
|
);
|
|
244
250
|
CREATE INDEX IF NOT EXISTS sent_msg ON sent(channel, ts);
|
|
245
251
|
CREATE INDEX IF NOT EXISTS sent_at ON sent(sent_at);
|
|
@@ -262,7 +268,7 @@ export function openSentLog(): Db | undefined {
|
|
|
262
268
|
db.exec(SCHEMA);
|
|
263
269
|
// Columns added after the table first shipped. ALTER fails harmlessly when
|
|
264
270
|
// the column is already there.
|
|
265
|
-
for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT"]) {
|
|
271
|
+
for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT", "pushed_at INTEGER", "pushed_answer TEXT"]) {
|
|
266
272
|
try { db.exec(`ALTER TABLE sent ADD COLUMN ${col}`); } catch { /* exists */ }
|
|
267
273
|
}
|
|
268
274
|
return db;
|
|
@@ -408,6 +414,14 @@ export function markAskDelivered(channel: string, ts: string, answer: string, cu
|
|
|
408
414
|
);
|
|
409
415
|
}
|
|
410
416
|
|
|
417
|
+
/** `ask --pending --push` told the asking agent this answer. */
|
|
418
|
+
export function markAskPushed(channel: string, ts: string, answer: string): void {
|
|
419
|
+
updateLog(
|
|
420
|
+
`UPDATE sent SET pushed_at = ?, pushed_answer = ? WHERE kind = 'ask' AND channel = ? AND ts = ?`,
|
|
421
|
+
Date.now(), answer, channel, ts,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
411
425
|
/** Logged asks nobody has collected yet, oldest first. `undefined` when the
|
|
412
426
|
* log cannot be opened at all (no SQLite in this runtime). */
|
|
413
427
|
export function uncollectedAsks(sinceMs: number): SentRow[] | undefined {
|