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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.51.2",
3
+ "version": "1.52.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
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` only reads the local
34
- * sent-log and the questions' live state. (`ask --waitFor` and
35
- * `poll --results` are NOT here: both can edit the posted message.) */
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: { sinceMs: number; json: boolean; channel?: string },
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 = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
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 pending) {
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 pending) {
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
- const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
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 {