slack-term 1.51.2 → 1.53.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.53.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,18 +3,20 @@
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
 
9
10
  import yargs, { type Options } from "yargs";
10
11
  import { hideBin } from "yargs/helpers";
11
- import { guardUrlBoundaries } from "./urlGuard.ts";
12
+ import { fixUrlBoundaries, guardUrlBoundaries } from "./urlGuard.ts";
12
13
  import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
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;
@@ -5695,7 +5825,7 @@ async function main(): Promise<void> {
5695
5825
  * prefix and thread check first). */
5696
5826
  type SendCli = {
5697
5827
  workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
5698
- "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
5828
+ "allow-url-adjacent"?: boolean | undefined; "fix-urls"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
5699
5829
  "as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
5700
5830
  json?: boolean | undefined;
5701
5831
  };
@@ -5704,7 +5834,7 @@ async function main(): Promise<void> {
5704
5834
  * its subscriptions. */
5705
5835
  type EditCli = {
5706
5836
  workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
5707
- force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
5837
+ force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "fix-urls"?: boolean | undefined; "channel-id"?: string | undefined;
5708
5838
  mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
5709
5839
  };
5710
5840
  const runEdit = async (argv: EditCli): Promise<void> => {
@@ -5722,6 +5852,7 @@ async function main(): Promise<void> {
5722
5852
  process.exit(1);
5723
5853
  }
5724
5854
  }
5855
+ if (argv["fix-urls"] && argv.newText) argv = { ...argv, newText: fixUrlBoundaries(argv.newText) };
5725
5856
  const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5726
5857
  if (argv.tick) args.tick = argv.tick;
5727
5858
  if (settings.prefix) args.lanePrefix = settings.prefix;
@@ -5768,6 +5899,7 @@ async function main(): Promise<void> {
5768
5899
  console.error("Error: message is empty");
5769
5900
  process.exit(1);
5770
5901
  }
5902
+ if (argv["fix-urls"]) argv = { ...argv, message: fixUrlBoundaries(argv.message!) };
5771
5903
  const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5772
5904
  if (argv.json) args.json = true;
5773
5905
  if (argv.code) args.code = argv.code;
@@ -6416,6 +6548,7 @@ async function main(): Promise<void> {
6416
6548
  .positional("message", { type: "string", demandOption: true })
6417
6549
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
6418
6550
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6551
+ .option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
6419
6552
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
6420
6553
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
6421
6554
  .option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
@@ -6594,6 +6727,7 @@ async function main(): Promise<void> {
6594
6727
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
6595
6728
  .option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
6596
6729
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6730
+ .option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
6597
6731
  .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
6598
6732
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
6599
6733
  async (argv) => {
@@ -6628,7 +6762,7 @@ async function main(): Promise<void> {
6628
6762
  if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
6629
6763
  await runSend({
6630
6764
  workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
6631
- "allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
6765
+ "allow-url-adjacent": argv["allow-url-adjacent"], "fix-urls": argv["fix-urls"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
6632
6766
  broadcast: argv.broadcast, mentions: argv.mentions, json: argv.json,
6633
6767
  });
6634
6768
  },
@@ -6842,7 +6976,10 @@ async function main(): Promise<void> {
6842
6976
  .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
6977
  .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
6978
  .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." })
6979
+ .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." })
6980
+ .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." })
6981
+ .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." })
6982
+ .option("interval", { type: "number", default: 10, describe: "With --watch: minutes between full passes" })
6846
6983
  .option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
6847
6984
  .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
6848
6985
  .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
@@ -6886,8 +7023,27 @@ async function main(): Promise<void> {
6886
7023
  console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
6887
7024
  process.exit(ASK_EXIT_ERROR);
6888
7025
  }
6889
- const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
7026
+ if (argv.watch && !argv.push) {
7027
+ console.error("Error: --watch keeps pushing answers — use it with --push.");
7028
+ process.exit(ASK_EXIT_ERROR);
7029
+ }
7030
+ const p: Parameters<typeof cmdAskPending>[1] = { sinceMs, json: !!argv.json, push: !!argv.push };
6890
7031
  if (argv.channel) p.channel = String(argv.channel);
7032
+ // The collect run must read with the same workspace this one does.
7033
+ if (argv.workspace) p.selfArgs = ["-w", String(argv.workspace)];
7034
+ if (argv.watch) {
7035
+ const min = Number(argv.interval);
7036
+ if (!(min > 0)) {
7037
+ console.error("Error: --interval needs a number of minutes above 0.");
7038
+ process.exit(ASK_EXIT_ERROR);
7039
+ }
7040
+ const since = String(argv.since);
7041
+ await askPushWatch(
7042
+ (only) => cmdAskPending(tokens, { ...p, sinceMs: parseSince(since) ?? sinceMs, watch: true, ...(only ? { only } : {}) }),
7043
+ () => parseSince(since) ?? sinceMs,
7044
+ min * 60_000,
7045
+ );
7046
+ }
6891
7047
  try {
6892
7048
  await cmdAskPending(tokens, p);
6893
7049
  } catch (e: unknown) {
@@ -7480,6 +7636,7 @@ async function main(): Promise<void> {
7480
7636
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
7481
7637
  .option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the ✅ form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
7482
7638
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
7639
+ .option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
7483
7640
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
7484
7641
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
7485
7642
  .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
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 {
package/ts/urlGuard.ts CHANGED
@@ -16,7 +16,15 @@ const URL_TOKEN = /<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f
16
16
  /** What may sit right before a bare URL and still let it autolink. */
17
17
  const SAFE_BEFORE = /[\t\n\v\f\r (*_~]/;
18
18
 
19
- export type UrlBoundaryIssue = { url: string; side: "before" | "after"; char: string };
19
+ /** `trailing`: the URL ends in ASCII punctuation (`)` without a matching `(`, or `.,:;!?`) that Slack may or may
20
+ * not take into the link: `(see https://x/a)` would link `https://x/a)`. Only warned, never refused. */
21
+ export type UrlBoundaryIssue = { url: string; side: "before" | "after" | "trailing"; char: string };
22
+ const TRAILING = /[.,:;!?]$/;
23
+ function trailingChar(url: string): string | undefined {
24
+ if (TRAILING.test(url)) return url.slice(-1);
25
+ if (url.endsWith(")") && count(url, "(") < count(url, ")")) return ")";
26
+ return undefined;
27
+ }
20
28
 
21
29
  /** Code spans and fences are shown verbatim and never autolinked, so a URL in
22
30
  * one is not a boundary problem; neither is a `[label](url)` link, which is
@@ -97,6 +105,11 @@ export function urlBoundaryIssues(text: string, { markdown = true }: { markdown?
97
105
  }
98
106
  const after = url.match(/[^\x00-\x7f]|>/);
99
107
  if (after) issues.push({ url, side: "after", char: after[0] });
108
+ else {
109
+ // `(https://x/a)`: the `)` closes the paren before the URL, as GFM and Slack read it
110
+ const t = trailingChar(url);
111
+ if (t && !(t === ")" && before === "(")) issues.push({ url, side: "trailing", char: t });
112
+ }
100
113
  }
101
114
  return issues;
102
115
  }
@@ -107,22 +120,25 @@ export function adjacentUrls(text: string): string[] {
107
120
 
108
121
  export function guardUrlBoundaries(
109
122
  text: string, allow = false, warn: (message: string) => void = console.error,
110
- { sides = ["before", "after"], markdown = true }: {
123
+ { sides = ["before", "after", "trailing"], markdown = true }: {
111
124
  sides?: ReadonlyArray<UrlBoundaryIssue["side"]>; markdown?: boolean;
112
125
  } = {},
113
126
  ): void {
114
- const issues = urlBoundaryIssues(text, { markdown }).filter((i) => sides.includes(i.side));
127
+ const all = urlBoundaryIssues(text, { markdown });
128
+ const trailing = all.filter((i) => i.side === "trailing" && sides.includes("trailing"));
129
+ if (trailing.length) warn(`⚠ URL ends in punctuation Slack may take into the link: ${trailing.map((i) => `${JSON.stringify(i.url)} → ${JSON.stringify(fixUrlBoundaries(`x ${i.url}`).slice(2))}`).join(", ")}. If the ${JSON.stringify(trailing[0]!.char)} is not part of the URL, send the suggested form (or --fix-urls).`);
130
+ const issues = all.filter((i) => i.side !== "trailing" && sides.includes(i.side));
115
131
  if (!issues.length) return;
116
132
  const described = issues.map((i) => i.side === "before"
117
133
  ? `${JSON.stringify(i.url)} (glued to ${JSON.stringify(i.char)} before it — Slack will NOT make it a link)`
118
- : `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it)`);
134
+ : `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it; non-ASCII in a URL is OK only if intended, otherwise send ${JSON.stringify(fixUrlBoundaries(`x ${i.url}`).slice(2))} or put an ASCII space after the URL)`);
119
135
  const fixes = issues.some((i) => i.side === "before")
120
136
  ? "Put an ASCII space (or a newline) before the URL, " : "";
121
137
  const message = `Ambiguous URL boundary: ${described.join(", ")}. ` +
122
138
  `${fixes}Put the URL on its own line, or wrap it as <url> / <url|label>. ` +
123
- "Use --allow-url-adjacent to warn only.";
139
+ "Use --fix-urls to wrap them, or --allow-url-adjacent to warn only.";
124
140
  if (!allow) throw new Error(message);
125
- warn(`Warning: ${message}`);
141
+ warn(`⚠ Warning: ${message}`);
126
142
  }
127
143
 
128
144
  type RichTextElement =
@@ -167,6 +183,27 @@ export function linkifyRichText(text: string): RichTextElement[] {
167
183
  return out;
168
184
  }
169
185
 
186
+ /** Optional auto-fix (`--fix-urls`): wrap every ambiguous bare URL as `<url>`, cut where Slack's link would end
187
+ * (at the first non-ASCII character, before trailing punctuation). Text, code spans and explicit links are kept. */
188
+ export function fixUrlBoundaries(text: string): string {
189
+ const masked = maskCode(text);
190
+ let out = "", pos = 0;
191
+ for (const m of masked.matchAll(URL_TOKEN)) {
192
+ const at = m.index ?? 0;
193
+ if (m[0].startsWith("<")) continue;
194
+ let url = text.slice(at, at + m[0].length);
195
+ const cut = url.search(/[^\x00-\x7f]|>/);
196
+ if (cut >= 0) url = url.slice(0, cut);
197
+ while (trailingChar(url)) url = url.slice(0, -1);
198
+ const before = at > 0 ? masked[at - 1]! : "";
199
+ const ambiguous = url.length < m[0].length || (before && !SAFE_BEFORE.test(before));
200
+ if (!ambiguous || !/^https?:\/\/./i.test(url)) continue;
201
+ out += text.slice(pos, at) + `<${url}>`;
202
+ pos = at + url.length;
203
+ }
204
+ return out + text.slice(pos);
205
+ }
206
+
170
207
  function count(s: string, ch: string): number {
171
208
  return s.split(ch).length - 1;
172
209
  }