slack-term 1.41.2 → 1.42.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.41.2",
3
+ "version": "1.42.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  } from "./poll.ts";
55
55
  import { seedReactionsInOrder } from "./reactionSeed.ts";
56
56
  import { buildListSchema, cellValue, completedColumn, findColumn, parseColumnSpec, parseListSchema, parseSets, type ListColumn } from "./lists.ts";
57
- import { attributionEnabled, attributionMetadata, captureAttribution, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
57
+ import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
58
58
  import {
59
59
  authTest,
60
60
  authScopes,
@@ -2740,12 +2740,33 @@ async function askEmit(
2740
2740
  }
2741
2741
 
2742
2742
 
2743
- /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2744
- * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2745
- * cheaply on its own schedule instead of parking a process on `--wait`. */
2746
- async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2747
- const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
2743
+ /** What one look at a question found besides the answer — read back by the
2744
+ * `--wait` loop between polls, and by `ask --pending` after its single look. */
2745
+ interface AskPollState {
2746
+ /** The earliest audience reply that picked none of the choices. */
2747
+ unchosen?: { text: string; who: string; ts: string; ambiguous: boolean };
2748
+ /** Set when the question has been voided. */
2749
+ voided?: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }>;
2750
+ /** The question's `reply_count` as last seen — undefined until seen. */
2751
+ replyCount?: number;
2752
+ /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2753
+ ownText?: string;
2754
+ }
2755
+
2756
+ /** The polling half of `--wait`: one `poll()` is one look at the question,
2757
+ * shared with `ask --pending`. `readOnly` drops the one WRITE a look can make
2758
+ * (the invalid-ballot notice edited into the question) and the progress lines
2759
+ * on stderr — `--pending` inspects questions other sessions are responsible
2760
+ * for, and must neither touch them nor narrate their state as if waiting. */
2761
+ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2762
+ poll: () => Promise<AskFound | null>;
2763
+ state: AskPollState;
2764
+ isAnswerer: (user: unknown) => user is string;
2765
+ candidates: string[];
2766
+ } {
2767
+ const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2748
2768
  const copy = ASK_COPY[ctx.lang];
2769
+ const state: AskPollState = {};
2749
2770
  const askerUserId = ctx.askerUserId;
2750
2771
  const askerBotId = ctx.askerBotId;
2751
2772
 
@@ -2756,25 +2777,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2756
2777
  const isAnswerer = (user: unknown): user is string =>
2757
2778
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
2758
2779
 
2759
- /** The question's `reply_count` as last seen — whether its thread is worth a
2760
- * fetch when collecting notes. Undefined until the question itself is seen. */
2761
- let replyCount: number | undefined;
2762
- /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2763
- let ownText: string | undefined;
2780
+ /** The question as last seen: its `reply_count` (whether its thread is worth
2781
+ * a fetch when collecting notes) and its text (what the ✅ rewrite keeps). */
2782
+ const seen = (own: Record<string, Json>): void => {
2783
+ state.replyCount = Number(own.reply_count) || 0;
2784
+ if (typeof own.text === "string") state.ownText = own.text;
2785
+ };
2764
2786
  /** Set when the question is voided while we wait — the wait ends there. */
2765
- let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
2766
2787
  const checkVoid = (own: Record<string, Json>): boolean => {
2767
2788
  const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
2768
- if (p?.kind === "void") voided = p;
2769
- return !!voided;
2789
+ if (p?.kind === "void") state.voided = p;
2790
+ return !!state.voided;
2770
2791
  };
2771
2792
 
2772
- /** The earliest reply from an answerer that picked none of the choices. Kept
2773
- * across polls so the timeout can report it, and so the operator is told once
2774
- * rather than on every tick. */
2775
- let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
2776
- let unchosenReported = "";
2777
-
2778
2793
  /** Every seed an answerer is on — not just the first. Changing your mind
2779
2794
  * leaves BOTH reactions in place (Slack only drops one when you actively
2780
2795
  * un-react), so taking the lowest index would answer with the abandoned
@@ -2800,6 +2815,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2800
2815
  * the API call when nothing changed, which is what makes this safe to run on
2801
2816
  * every poll tick rather than once per process. */
2802
2817
  async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
2818
+ if (readOnly) return;
2803
2819
  const text = typeof msg.text === "string" ? msg.text : "";
2804
2820
  if (!text) return;
2805
2821
  const next = applyInvalidNotice(text, offenders, ctx.lang);
@@ -2822,7 +2838,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2822
2838
  for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2823
2839
  if (otherReported.has(u)) continue;
2824
2840
  otherReported.add(u);
2825
- console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2841
+ if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2826
2842
  }
2827
2843
  }
2828
2844
 
@@ -2845,7 +2861,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2845
2861
  const pillsPerUser = new Map<string, number>();
2846
2862
  for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
2847
2863
  const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
2848
- console.error(multi.length
2864
+ if (!readOnly) console.error(multi.length
2849
2865
  ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
2850
2866
  : ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
2851
2867
  // Whoever is on more than one pill — and ONLY them. Naming everyone on any
@@ -2895,9 +2911,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2895
2911
  // must not hide it behind the refuse-to-guess path. Within a class, the
2896
2912
  // earliest wins.
2897
2913
  const ambiguous = match.kind === "ambiguous";
2914
+ const unchosen = state.unchosen;
2898
2915
  if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
2899
2916
  (unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
2900
- unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2917
+ state.unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2901
2918
  }
2902
2919
  }
2903
2920
  return null;
@@ -2910,7 +2927,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2910
2927
  // longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
2911
2928
  // persists on purpose — it is what keeps the operator from being told the
2912
2929
  // same thing on every tick.
2913
- unchosen = undefined;
2930
+ delete state.unchosen;
2914
2931
  // A question that is itself a thread reply is invisible to
2915
2932
  // `conversations.history`, so the entire poll — reactions included — has to
2916
2933
  // come from the thread.
@@ -2919,8 +2936,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2919
2936
  const messages = asArray(rep.messages).map(asRecord);
2920
2937
  const own = messages.find((m) => m.ts === ts);
2921
2938
  if (own) {
2922
- replyCount = Number(own.reply_count) || 0;
2923
- if (typeof own.text === "string") ownText = own.text;
2939
+ seen(own);
2924
2940
  if (checkVoid(own)) return null;
2925
2941
  const byReaction = await answerFromReactions(own);
2926
2942
  if (byReaction) return byReaction;
@@ -2936,8 +2952,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2936
2952
  const own = messages.find((m) => m.ts === ts);
2937
2953
 
2938
2954
  if (own) {
2939
- replyCount = Number(own.reply_count) || 0;
2940
- if (typeof own.text === "string") ownText = own.text;
2955
+ seen(own);
2941
2956
  if (checkVoid(own)) return null;
2942
2957
  }
2943
2958
  // A reaction is the intended path, so it wins when both are present.
@@ -2970,6 +2985,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2970
2985
  return null;
2971
2986
  }
2972
2987
 
2988
+ return { poll, state, isAnswerer, candidates };
2989
+ }
2990
+
2991
+ /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2992
+ * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2993
+ * cheaply on its own schedule instead of parking a process on `--wait`. */
2994
+ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2995
+ const { channelId, ts, question, reactable, threadOnly, cookie, timeout, shown } = ctx;
2996
+ const askerUserId = ctx.askerUserId;
2997
+ const askerBotId = ctx.askerBotId;
2998
+ const { poll, state, isAnswerer, candidates } = askPoller(token, ctx);
2999
+ /** Kept across polls so the operator is told about one reply once, rather
3000
+ * than on every tick. */
3001
+ let unchosenReported = "";
3002
+
2973
3003
  /** Stamp the question resolved. Removing our seeds clears the pills that still
2974
3004
  * invite an answer; the answerer's own reaction cannot be removed by us and
2975
3005
  * stays put — which is exactly what leaves the chosen number visible. */
@@ -2996,7 +3026,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2996
3026
  // The body and the chosen option stay; only what no longer applies goes.
2997
3027
  // Built from the message as it is NOW (an edit made in Slack after asking
2998
3028
  // is kept). Not seen this run — the bare form, as before.
2999
- const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
3029
+ const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
3000
3030
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
3001
3031
  // Swap the marker for the resolved one so search reflects reality:
3002
3032
  // `has::question:` should list what still needs answering, not everything
@@ -3019,6 +3049,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3019
3049
  const deadline = Date.now() + timeout * 1000;
3020
3050
  let errors = 0;
3021
3051
  let delay = ASK_POLL_MIN_MS;
3052
+ let unchosen: AskPollState["unchosen"];
3022
3053
  for (;;) {
3023
3054
  let found: AskFound | null = null;
3024
3055
  try {
@@ -3033,6 +3064,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3033
3064
  }
3034
3065
  console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3035
3066
  }
3067
+ unchosen = state.unchosen;
3068
+ const voided = state.voided;
3036
3069
  if (voided) {
3037
3070
  askReportVoid(voided, shown);
3038
3071
  if (ctx.json) {
@@ -3050,7 +3083,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3050
3083
  // BEFORE ✅ is written, and delivered right after — ✅ is what tells a
3051
3084
  // later --waitFor the question is settled.
3052
3085
  const notes = await askCollectNotes(token, {
3053
- channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
3086
+ channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
3054
3087
  ...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
3055
3088
  ...(cookie ? { cookie } : {}),
3056
3089
  }, ctx.after, (m) => m.ts === found!.ts);
@@ -3059,6 +3092,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3059
3092
  status: "answered", answer: found.answer, how: found.how, notes,
3060
3093
  cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
3061
3094
  };
3095
+ // Collected: the local sent-log row (if this machine asked it) drops off
3096
+ // `ask --pending`. Only here, where the answer actually reaches a caller.
3097
+ markAskCollected(channelId, ts, found.answer, r.cursor);
3062
3098
  if (found.who) r.who = found.who;
3063
3099
  await askEmit(token, ctx, r);
3064
3100
  process.exit(0);
@@ -3079,6 +3115,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3079
3115
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3080
3116
  console.error(` ${shown}`);
3081
3117
  console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
3118
+ // Delivered, not collected: `--pending` will not report THIS reply again,
3119
+ // but still reports a pill pressed after it.
3120
+ markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3082
3121
  if (ctx.json) {
3083
3122
  await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3084
3123
  } else {
@@ -3434,6 +3473,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
3434
3473
  channelId = m[1]!;
3435
3474
  ts = m[2]!;
3436
3475
  }
3476
+ return askFetchAt(token, { channelId, ts, threadTs }, cookie);
3477
+ }
3478
+
3479
+ /** `askFetchByLink` for a location already known — e.g. a sent-log row, whose
3480
+ * `thread_ts` says where a question posted into a thread lives. */
3481
+ async function askFetchAt(token: string, loc: { channelId: string; ts: string; threadTs?: string | undefined }, cookie?: string): Promise<
3482
+ { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> }
3483
+ > {
3484
+ const { channelId, ts } = loc;
3485
+ const threadTs = loc.threadTs && loc.threadTs !== loc.ts ? loc.threadTs : undefined;
3437
3486
  let msg: Record<string, Json> | undefined;
3438
3487
  if (threadTs) {
3439
3488
  const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
@@ -3635,8 +3684,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3635
3684
  let answer: string | undefined;
3636
3685
  if (token) {
3637
3686
  try {
3638
- const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
3639
- if (!got?.msg) state = "gone";
3687
+ const got = await askFetchAt(token, { channelId: r.channel, ts: r.ts, threadTs: r.thread_ts ?? undefined }, r.as_bot ? undefined : tokens.cookie);
3688
+ if (!got.msg) state = "gone";
3640
3689
  else {
3641
3690
  const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
3642
3691
  state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
@@ -3674,6 +3723,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3674
3723
  }
3675
3724
  }
3676
3725
 
3726
+ /** One answered-but-uncollected ask, as `--pending` reports it. */
3727
+ interface AskPendingItem {
3728
+ row: SentRow;
3729
+ link: string;
3730
+ question: string;
3731
+ answer: string;
3732
+ how: string;
3733
+ /** A free-text reply that picked none of the choices — not a decision. */
3734
+ freeText: boolean;
3735
+ alive: boolean;
3736
+ /** The ready-to-run `--waitFor` line, cursor included. */
3737
+ collect: string;
3738
+ }
3739
+
3740
+ /** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
3741
+ * that somebody answered and nobody collected, grouped by the asking session,
3742
+ * with whether that agent is still alive.
3743
+ *
3744
+ * Why it exists: of 71 asks over 8 days, 13 had a pill pressed that nobody
3745
+ * ever read back (2026-09-27). The answer was sitting in Slack; the agent that
3746
+ * asked had moved on or died, and nothing connected the two.
3747
+ *
3748
+ * Read-only, against Slack AND the local log: each open question gets one look
3749
+ * through the same poller `--wait` uses, with its one write disabled. It only
3750
+ * REPORTS — collecting is `slack ask --waitFor=<link>`, printed per item,
3751
+ * which is also what stamps ✅ and takes the ask off this list. Nothing is
3752
+ * relayed into another process: whoever reads the list (the asking agent, or
3753
+ * a person) runs the collect line. */
3754
+ async function cmdAskPending(
3755
+ tokens: { user?: string; bot?: string; cookie?: string },
3756
+ opts: { sinceMs: number; json: boolean; channel?: string },
3757
+ ): Promise<void> {
3758
+ const all = uncollectedAsks(opts.sinceMs);
3759
+ if (!all) {
3760
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
3761
+ process.exit(ASK_EXIT_ERROR);
3762
+ }
3763
+ const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3764
+ const pending: AskPendingItem[] = [];
3765
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3766
+ const problems: string[] = [];
3767
+ // The log is machine-wide, but a token reads one workspace. Asks recorded
3768
+ // under another workspace are counted and named, not probed with the wrong
3769
+ // token (which would only fail as channel_not_found).
3770
+ const currentTeam = tokens.user
3771
+ ? (await selfIdentity(tokens.user, tokens.cookie))?.team ?? ""
3772
+ : tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
3773
+ const otherTeams = new Map<string, number>();
3774
+
3775
+ for (const row of rows) {
3776
+ if (row.team && currentTeam && row.team !== currentTeam) {
3777
+ otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
3778
+ continue;
3779
+ }
3780
+ const link = row.permalink || `${row.channel}:${row.ts}`;
3781
+ const asBot = !!row.as_bot;
3782
+ const token = asBot ? tokens.bot : tokens.user;
3783
+ if (!token) {
3784
+ problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
3785
+ continue;
3786
+ }
3787
+ const cookie = asBot ? undefined : tokens.cookie;
3788
+ // Resume from where the last delivery to a caller ended (an exit 5
3789
+ // free-text reply), so that reply is not reported again — and the collect
3790
+ // line carries the same cursor, so `--waitFor` does not re-deliver it.
3791
+ const after = row.delivered_cursor ?? undefined;
3792
+ try {
3793
+ const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
3794
+ const got = await askFetchAt(token, loc, cookie);
3795
+ if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
3796
+ const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
3797
+ const parsed = q.parsed;
3798
+ // ✅ already stamped: some `--wait`/`--waitFor` collected it (another
3799
+ // machine, another token, or a build before collection was logged).
3800
+ if (parsed.kind === "resolved") { count.resolved++; continue; }
3801
+ if (parsed.kind === "void") { count.void++; continue; }
3802
+ if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
3803
+ if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
3804
+ const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
3805
+ const poller = askPoller(token, ctx, true);
3806
+ const found = await poller.poll();
3807
+ const unchosen = poller.state.unchosen;
3808
+ if (poller.state.voided) { count.void++; continue; }
3809
+ const collect = askResumeCommand(link, asBot, after);
3810
+ const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3811
+ if (found) {
3812
+ pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
3813
+ } else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
3814
+ pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
3815
+ } else if (unchosen?.ambiguous) {
3816
+ count.ambiguous++;
3817
+ } else {
3818
+ count.unanswered++;
3819
+ }
3820
+ } catch (e: unknown) {
3821
+ problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
3822
+ }
3823
+ }
3824
+
3825
+ if (opts.json) {
3826
+ for (const it of pending) {
3827
+ const r = it.row;
3828
+ console.log(JSON.stringify({
3829
+ id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
3830
+ question: it.question, answer: it.answer, how: it.how, free_text: it.freeText,
3831
+ 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 },
3832
+ delivered_at: r.delivered_at ?? null,
3833
+ collect: it.collect,
3834
+ }));
3835
+ }
3836
+ } else {
3837
+ // Grouped by the session that asked — the unit that has to be told.
3838
+ const groups = new Map<string, AskPendingItem[]>();
3839
+ for (const it of pending) {
3840
+ const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
3841
+ groups.set(key, [...(groups.get(key) ?? []), it]);
3842
+ }
3843
+ for (const [key, items] of groups) {
3844
+ const r = items[0]!.row;
3845
+ console.log(
3846
+ `${stripTerminalControls(key)} ${stripTerminalControls(r.cli ?? "?")} pid ${r.agent_pid ?? r.pid} ${items[0]!.alive ? "(alive)" : "(gone)"}` +
3847
+ `${r.git_branch ? ` branch=${stripTerminalControls(r.git_branch)}` : ""} cwd=${stripTerminalControls(r.cwd)}`,
3848
+ );
3849
+ for (const it of items) {
3850
+ console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
3851
+ console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
3852
+ console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
3853
+ console.log(` collect: ${stripTerminalControls(it.collect)}`);
3854
+ }
3855
+ }
3856
+ }
3857
+ for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
3858
+ for (const [team, n] of otherTeams) {
3859
+ console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3860
+ }
3861
+ console.error(
3862
+ `${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
3863
+ (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3864
+ (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3865
+ (count.void ? `, ${count.void} 件は無効化済み` : "") +
3866
+ ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3867
+ );
3868
+ }
3869
+
3677
3870
  /** A question about to be voided: where it is, and what it parsed as. */
3678
3871
  interface AskVoidTarget {
3679
3872
  link: string;
@@ -3858,23 +4051,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3858
4051
  ts = m[2]!;
3859
4052
  }
3860
4053
 
3861
- // Fetch the one message. A question posted into a thread is not in history at
3862
- // all, so where the permalink says it is a reply, read it from the thread.
3863
- let msg: Record<string, Json> | undefined;
3864
- if (threadTs) {
3865
- const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
3866
- msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
3867
- } else {
3868
- const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
3869
- msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
3870
- }
3871
- if (!msg) {
4054
+ const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
4055
+ if (!got.msg) {
3872
4056
  console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
3873
4057
  process.exit(ASK_EXIT_ERROR);
3874
4058
  }
3875
-
3876
- const text = typeof msg.text === "string" ? msg.text : "";
3877
- const parsed = askParseMessage(text);
4059
+ const msg = got.msg;
4060
+ const q = await askReadBack(token, { channelId, ts, msg }, args.cookie);
4061
+ const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
3878
4062
  if (parsed.kind === "other") {
3879
4063
  console.error(
3880
4064
  `Error: これは \`slack ask\` の質問として読み取れません。\n` +
@@ -3887,23 +4071,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3887
4071
  process.exit(ASK_EXIT_ERROR);
3888
4072
  }
3889
4073
 
3890
- // Who may answer, recovered from the tags the readers can see. They are
3891
- // already `<@U…>`-encoded in the stored text, so no directory lookup is
3892
- // needed — and the asker is excluded here for the same reason as when
3893
- // posting: their tag of themselves grants nothing, and their reactions are
3894
- // the seeds.
3895
- const askerUserId = typeof msg.user === "string" ? msg.user : "";
3896
- const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
3897
- const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
3898
- const audience = new Set<string>();
3899
- for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
3900
- if (m[1] !== askerUserId) audience.add(m[1]!);
3901
- }
3902
- if (!audience.size && !broadcast) {
3903
- // A 1:1 DM needs no tag — the other party is the only person it could be.
3904
- const counterpart = await imCounterpart(token, channelId, args.cookie);
3905
- if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
3906
- }
3907
4074
  if (parsed.kind === "void") {
3908
4075
  askReportVoid(parsed, args.link);
3909
4076
  if (args.json) {
@@ -3955,10 +4122,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3955
4122
  ...(args.cookie ? { cookie: args.cookie } : {}),
3956
4123
  }, after, isAnswerReply);
3957
4124
  }
4125
+ const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
3958
4126
  await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3959
- status: "answered", answer: parsed.answer, notes,
3960
- cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
4127
+ status: "answered", answer: parsed.answer, notes, cursor,
3961
4128
  });
4129
+ markAskCollected(channelId, ts, parsed.answer, cursor);
3962
4130
  return;
3963
4131
  }
3964
4132
 
@@ -3970,27 +4138,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3970
4138
  if (args.timeout > 0) {
3971
4139
  console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
3972
4140
  }
4141
+ await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
4142
+ }
4143
+
4144
+ /** A posted question read back: what it parsed as, and who may answer it. */
4145
+ interface AskReadBack {
4146
+ text: string;
4147
+ parsed: ReturnType<typeof askParseMessage>;
4148
+ audience: Set<string>;
4149
+ broadcast: boolean;
4150
+ askerUserId: string;
4151
+ askerBotId: string;
4152
+ }
4153
+
4154
+ /** Recover who may answer from the message alone. Shared by `--waitFor` and
4155
+ * `--pending` — nothing about a question is stored, so both read it back. */
4156
+ async function askReadBack(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, cookie?: string): Promise<AskReadBack> {
4157
+ const { msg } = t;
4158
+ const text = typeof msg.text === "string" ? msg.text : "";
4159
+ const parsed = askParseMessage(text);
4160
+ // Who may answer, recovered from the tags the readers can see. They are
4161
+ // already `<@U…>`-encoded in the stored text, so no directory lookup is
4162
+ // needed — and the asker is excluded here for the same reason as when
4163
+ // posting: their tag of themselves grants nothing, and their reactions are
4164
+ // the seeds.
4165
+ const askerUserId = typeof msg.user === "string" ? msg.user : "";
4166
+ const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
4167
+ const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
4168
+ const audience = new Set<string>();
4169
+ for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
4170
+ if (m[1] !== askerUserId) audience.add(m[1]!);
4171
+ }
4172
+ if (parsed.kind !== "other" && !audience.size && !broadcast) {
4173
+ // A 1:1 DM needs no tag — the other party is the only person it could be.
4174
+ const counterpart = await imCounterpart(token, t.channelId, cookie);
4175
+ if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
4176
+ }
4177
+ return { text, parsed, audience, broadcast, askerUserId, askerBotId };
4178
+ }
4179
+
4180
+ function askWaitCtxFrom(
4181
+ q: AskReadBack,
4182
+ parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4183
+ loc: { channelId: string; ts: string; threadTs?: string | undefined },
4184
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4185
+ ): AskWaitCtx {
3973
4186
  const ctx: AskWaitCtx = {
3974
- channelId,
3975
- ts,
4187
+ channelId: loc.channelId,
4188
+ ts: loc.ts,
3976
4189
  question: parsed.question,
3977
4190
  reactable: parsed.reactable,
3978
4191
  overflow: parsed.overflow,
3979
4192
  threadOnly: parsed.threadOnly,
3980
4193
  lang: parsed.lang,
3981
- audience,
3982
- broadcast,
3983
- askerUserId,
3984
- askerBotId,
3985
- timeout: args.timeout,
3986
- shown: args.link,
3987
- asBot: args.asBot,
3988
- json: args.json,
4194
+ audience: q.audience,
4195
+ broadcast: q.broadcast,
4196
+ askerUserId: q.askerUserId,
4197
+ askerBotId: q.askerBotId,
4198
+ timeout: o.timeout,
4199
+ shown: o.link,
4200
+ asBot: o.asBot,
4201
+ json: o.json,
3989
4202
  };
3990
- if (threadTs) ctx.threadParentTs = threadTs;
3991
- if (args.cookie) ctx.cookie = args.cookie;
3992
- if (args.after) ctx.after = args.after;
3993
- await askWaitForAnswer(token, ctx);
4203
+ if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4204
+ if (o.cookie) ctx.cookie = o.cookie;
4205
+ if (o.after) ctx.after = o.after;
4206
+ return ctx;
3994
4207
  }
3995
4208
 
3996
4209
  // --- poll ---
@@ -5536,13 +5749,15 @@ async function main(): Promise<void> {
5536
5749
  .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." })
5537
5750
  .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." })
5538
5751
  .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." })
5752
+ .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." })
5753
+ .option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
5539
5754
  .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
5540
5755
  .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
5541
- .option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
5756
+ .option("channel", { type: "string", describe: "With --ls / --pending: only this channel (id, or the target as typed)" })
5542
5757
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5543
5758
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5544
5759
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5545
- .option("json", { type: "boolean", default: false, describe: "With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
5760
+ .option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
5546
5761
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5547
5762
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5548
5763
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
@@ -5554,7 +5769,11 @@ async function main(): Promise<void> {
5554
5769
  // slack ask collect <link> = --waitFor
5555
5770
  // Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
5556
5771
  // the bare verbs `void` / `collect` stay as aliases.
5557
- if (argv.ls) {
5772
+ if (argv.ls || argv.pending) {
5773
+ if (argv.ls && argv.pending) {
5774
+ console.error("Error: --ls and --pending are separate lists — pick one.");
5775
+ process.exit(ASK_EXIT_ERROR);
5776
+ }
5558
5777
  const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
5559
5778
  const tokens: { user?: string; bot?: string; cookie?: string } = {};
5560
5779
  if (user) tokens.user = user;
@@ -5562,6 +5781,26 @@ async function main(): Promise<void> {
5562
5781
  if (bot) tokens.bot = bot;
5563
5782
  const c = ck(argv as W);
5564
5783
  if (c) tokens.cookie = c;
5784
+ if (argv.pending) {
5785
+ if (argv.target || argv.question || argv.waitFor || argv.wait || argv.void || argv.edit) {
5786
+ console.error("Error: --pending reads the local log — do not also pass a target/question or another ask action.");
5787
+ process.exit(ASK_EXIT_ERROR);
5788
+ }
5789
+ const sinceMs = parseSince(String(argv.since));
5790
+ if (sinceMs === undefined) {
5791
+ console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
5792
+ process.exit(ASK_EXIT_ERROR);
5793
+ }
5794
+ const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
5795
+ if (argv.channel) p.channel = String(argv.channel);
5796
+ try {
5797
+ await cmdAskPending(tokens, p);
5798
+ } catch (e: unknown) {
5799
+ console.error(friendlySlackError(e));
5800
+ process.exit(ASK_EXIT_ERROR);
5801
+ }
5802
+ return;
5803
+ }
5565
5804
  const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
5566
5805
  state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
5567
5806
  };