slack-term 1.43.0 → 1.45.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/ts/cli.ts CHANGED
@@ -20,11 +20,16 @@ import { agentCommands } from "./agent.ts";
20
20
  import {
21
21
  ASK_KEYCAPS,
22
22
  ASK_MARKER,
23
+ ASK_OTHER_MARKER,
24
+ ASK_OTHER_MARKERS,
25
+ ASK_EXPLAIN_MARKER,
23
26
  ASK_RESOLVED_MARKER,
24
27
  ASK_MAX_REACTION_CHOICES,
25
28
  askBuildText,
29
+ askIsExplainNote,
26
30
  askBuildResolvedText,
27
31
  askParseMessage,
32
+ askSeedMarker,
28
33
  askExplainReject,
29
34
  askFlatten,
30
35
  askMatchChoice,
@@ -93,6 +98,7 @@ import {
93
98
  openDm,
94
99
  parseSlackPermalink,
95
100
  replies,
101
+ repliesPage,
96
102
  resolveChannel,
97
103
  resolveUserId,
98
104
  search,
@@ -2457,6 +2463,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2457
2463
  // pressed later still settles it (`--waitFor … --after=<reply ts>`).
2458
2464
  // 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
2459
2465
  // answered; stop waiting. stdout stays empty.
2466
+ // 7 only with --explain-exit: someone pressed ℹ️ (explain) — the question is
2467
+ // STILL OPEN; explain in its thread, then collect again. stdout stays empty.
2460
2468
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
2461
2469
 
2462
2470
  const ASK_EXIT_TIMEOUT = 2;
@@ -2476,6 +2484,20 @@ const ASK_EXIT_FREETEXT = 5;
2476
2484
  * Neither "open" nor "answered": a monitor that kept waiting on it would wait
2477
2485
  * forever, and one that read it as answered would act on nothing. */
2478
2486
  const ASK_EXIT_VOID = 6;
2487
+ /** `--explain-exit` only: an audience member pressed ℹ️ and asked for an
2488
+ * explanation. Not an answer and not a state of the question — it stays open —
2489
+ * but a caller parked on `--waitFor` cannot explain anything until it hears.
2490
+ * Opt-in, so no existing caller meets a code it does not know. */
2491
+ const ASK_EXIT_EXPLAIN = 7;
2492
+ /** How many 200-reply pages the explain-note lookup reads before giving up. */
2493
+ const ASK_EXPLAIN_NOTE_MAX_PAGES = 10;
2494
+
2495
+ /** Whether a new question offers the ℹ️ explain pill when neither --explain nor
2496
+ * --no-explain is given: `SLACK_TERM_ASK_EXPLAIN=1` turns it on for every ask
2497
+ * from a fleet, without every caller having to remember the flag. */
2498
+ function askExplainDefault(): boolean {
2499
+ return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
2500
+ }
2479
2501
 
2480
2502
  const ASK_PHI = 1.618033988749895;
2481
2503
  const ASK_POLL_MIN_MS = 1000;
@@ -2500,6 +2522,10 @@ interface AskArgs {
2500
2522
  userId?: string;
2501
2523
  asBot?: boolean;
2502
2524
  wait?: boolean;
2525
+ /** `--explain`: offer the ℹ️ pill — "explain this first", never an answer. */
2526
+ explain?: boolean;
2527
+ /** `--explain-exit` with `--wait`: exit 7 when an explanation is requested. */
2528
+ explainExit?: boolean;
2503
2529
  /** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
2504
2530
  json?: boolean;
2505
2531
  timeout?: number;
@@ -2589,6 +2615,9 @@ interface AskWaitCtx {
2589
2615
  * nothing". */
2590
2616
  overflow: string[];
2591
2617
  threadOnly: boolean;
2618
+ /** The marker reaction this question was seeded with (➕, or ❓ on a
2619
+ * free-text question or one posted before ➕) — what the ✅ swap takes off. */
2620
+ seedMarker: string;
2592
2621
  /** The language the question's copy is in — the ✅ stamp and the
2593
2622
  * invalid-ballot line are written in the same one. */
2594
2623
  lang: AskLang;
@@ -2614,6 +2643,11 @@ interface AskWaitCtx {
2614
2643
  /** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
2615
2644
  * the bare answer. Exit codes are the same either way. */
2616
2645
  json: boolean;
2646
+ /** `--explain-exit`: stop with exit 7 the first time an explanation is
2647
+ * requested (the run that posts the thread note), instead of waiting on. */
2648
+ explainExit?: boolean;
2649
+ /** The question offers the ℹ️ pill — so the ✅ rewrite takes our seed off. */
2650
+ explain?: boolean;
2617
2651
  }
2618
2652
 
2619
2653
  /** A reply from the question's audience that is NOT the answer — an extra
@@ -2682,7 +2716,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
2682
2716
  // integration's) is excluded here, so nothing echoes back as a "note".
2683
2717
  .filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
2684
2718
  .filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
2685
- .filter((m) => typeof m.text === "string" && m.text.trim() !== "")
2719
+ .filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
2686
2720
  // Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
2687
2721
  .sort((a, b) => Number(a.ts) - Number(b.ts))
2688
2722
  .filter((m) => !skip(m));
@@ -2712,12 +2746,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
2712
2746
  async function askEmit(
2713
2747
  token: string,
2714
2748
  o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
2715
- r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
2749
+ r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void" | "explain"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string; explain?: string[] },
2716
2750
  ): Promise<void> {
2717
2751
  if (o.json) {
2718
2752
  console.log(JSON.stringify({
2719
2753
  status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
2720
2754
  ...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
2755
+ // Who is asking for an explanation right now. Only when someone is, so
2756
+ // the object every existing caller reads is unchanged otherwise.
2757
+ ...(r.explain?.length ? { explain: r.explain } : {}),
2721
2758
  notes: r.notes, cursor: r.cursor, permalink: o.shown,
2722
2759
  }));
2723
2760
  return;
@@ -2754,6 +2791,13 @@ interface AskPollState {
2754
2791
  replyCount?: number;
2755
2792
  /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2756
2793
  ownText?: string;
2794
+ /** Who is on ℹ️ (explain) right now — audience only, never a bot, never the
2795
+ * asker. Recomputed every pass, so a press taken back stops counting. Only
2796
+ * on a question that offers ℹ️ (`ctx.explain`). */
2797
+ explainNow: string[];
2798
+ /** THIS run posted the explain thread note — the one run `--explain-exit`
2799
+ * stops on. Never set read-only (`--pending` posts nothing). */
2800
+ explainPosted: boolean;
2757
2801
  }
2758
2802
 
2759
2803
  /** The polling half of `--wait`: one `poll()` is one look at the question,
@@ -2769,7 +2813,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2769
2813
  } {
2770
2814
  const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2771
2815
  const copy = ASK_COPY[ctx.lang];
2772
- const state: AskPollState = {};
2816
+ const state: AskPollState = { explainNow: [], explainPosted: false };
2773
2817
  const askerUserId = ctx.askerUserId;
2774
2818
  const askerBotId = ctx.askerBotId;
2775
2819
 
@@ -2831,21 +2875,111 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2831
2875
  }
2832
2876
  }
2833
2877
 
2834
- /** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
2835
- * never resolves anything; the reply that should follow it does. Reported
2836
- * once per person so the operator knows a reply is on its way rather than
2837
- * reading the silence as "nobody looked". */
2878
+ /** Who has pressed ➕ ("other") — or ❓, the other pill on questions posted
2879
+ * before ➕, which is still pressed out of habit. Not an answer — neither
2880
+ * carries text — so it never resolves anything; the reply that should
2881
+ * follow it does. Reported once per person so the operator knows a reply is
2882
+ * on its way rather than reading the silence as "nobody looked". */
2838
2883
  const otherReported = new Set<string>();
2839
2884
  function noteOther(msg: Record<string, Json>): void {
2840
- const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
2885
+ for (const r of asArray(msg.reactions).map(asRecord)) {
2886
+ if (typeof r.name !== "string" || !ASK_OTHER_MARKERS.includes(r.name)) continue;
2887
+ const glyph = r.name === ASK_OTHER_MARKER ? "➕" : "❓";
2888
+ for (const u of asArray(r.users).filter(isAnswerer)) {
2889
+ if (otherReported.has(u)) continue;
2890
+ otherReported.add(u);
2891
+ if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
2892
+ }
2893
+ }
2894
+ }
2895
+
2896
+ const explainReported = new Set<string>();
2897
+ const botCache = new Map<string, boolean>();
2898
+ /** The thread note is settled for this run (found already there, or posted). */
2899
+ let explainNoted = false;
2900
+ /** Null when it cannot be told — not cached, so the next pass asks again.
2901
+ * Until then the press does not count: a bot must never post the note or
2902
+ * wake an --explain-exit caller, and a person is only delayed a tick. */
2903
+ async function isBotUser(u: string): Promise<boolean | null> {
2904
+ if (u === "USLACKBOT") return true;
2905
+ const hit = botCache.get(u);
2906
+ if (hit !== undefined) return hit;
2907
+ try {
2908
+ // A reply with no boolean is_bot tells us nothing — unknown, not human.
2909
+ const bot = asRecord(asRecord((await userInfo(token, u, cookie)) as Json).user).is_bot;
2910
+ if (typeof bot !== "boolean") return null;
2911
+ botCache.set(u, bot);
2912
+ return bot;
2913
+ } catch {
2914
+ return null;
2915
+ }
2916
+ }
2917
+ /** One thread note per QUESTION, however many presses and runs: the thread
2918
+ * itself is the record, so there is no local state to lose. The note is
2919
+ * recognised by its exact copy (either language), whoever posted it — the
2920
+ * identity that collects now need not be the one that collected before, and
2921
+ * under @here it may even be in the audience.
2922
+ *
2923
+ * The whole thread after the question is read (paged), not one page: a
2924
+ * question posted into a long thread may have more than a page before its
2925
+ * note. Past the page cap it is not posted at all ("exists") rather than
2926
+ * risk a duplicate.
2927
+ *
2928
+ * "unread": the thread could not be read — nothing is posted, and the next
2929
+ * pass tries again rather than risk a duplicate. "failed": the post itself
2930
+ * failed — not retried this run (no error every tick) and NOT a reason for
2931
+ * --explain-exit, which promises a note is up; the next run tries again. */
2932
+ async function postExplainNote(): Promise<"exists" | "posted" | "unread" | "failed"> {
2933
+ const root = ctx.threadParentTs ?? ts;
2934
+ try {
2935
+ let cursor: string | undefined;
2936
+ let pages = 0;
2937
+ for (;;) {
2938
+ const rep = asRecord((await repliesPage(token, channelId, root, { oldest: ts, ...(cursor ? { cursor } : {}) }, cookie)) as Json);
2939
+ if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(ts) && askIsExplainNote(m.text))) return "exists";
2940
+ cursor = String(asRecord(rep.response_metadata).next_cursor ?? "") || undefined;
2941
+ if (!cursor) break;
2942
+ if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
2943
+ }
2944
+ } catch (e: unknown) {
2945
+ console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
2946
+ return "unread";
2947
+ }
2948
+ try {
2949
+ await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
2950
+ } catch (e: unknown) {
2951
+ // The press is still reported on stderr; the note is a courtesy.
2952
+ console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
2953
+ return "failed";
2954
+ }
2955
+ return "posted";
2956
+ }
2957
+ async function noteExplain(msg: Record<string, Json>): Promise<void> {
2958
+ const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_EXPLAIN_MARKER);
2959
+ const now: string[] = [];
2841
2960
  for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2842
- if (otherReported.has(u)) continue;
2843
- otherReported.add(u);
2844
- if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2961
+ if ((await isBotUser(u)) !== false) continue;
2962
+ now.push(u);
2963
+ if (explainReported.has(u)) continue;
2964
+ explainReported.add(u);
2965
+ if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
2966
+ }
2967
+ state.explainNow = now;
2968
+ // Read-only (`--pending`): the press is reported, the note is not posted —
2969
+ // that is the waiting session's to do.
2970
+ if (now.length && !explainNoted && !readOnly) {
2971
+ const r = await postExplainNote();
2972
+ if (r !== "unread") {
2973
+ explainNoted = true;
2974
+ state.explainPosted = r === "posted";
2975
+ }
2845
2976
  }
2846
2977
  }
2847
2978
 
2848
2979
  async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
2980
+ // Only a question that offers ℹ️ treats it as a request — on any other,
2981
+ // ℹ️ is just an emoji somebody added.
2982
+ if (ctx.explain) await noteExplain(msg);
2849
2983
  if (reactable.length) noteOther(msg);
2850
2984
  const picks = humanChoices(msg);
2851
2985
  // Nothing ambiguous any more — take the notice back down if one is up.
@@ -2895,7 +3029,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2895
3029
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
2896
3030
  if (Number(m.ts) >= before) break;
2897
3031
  const t = typeof m.text === "string" ? m.text.trim() : "";
2898
- if (!t) continue;
3032
+ // Our ℹ️ note is bookkeeping — only on a question that offers ℹ️, so an
3033
+ // older question's reply is never second-guessed.
3034
+ if (!t || (ctx.explain && askIsExplainNote(t))) continue;
2899
3035
  // A question asked WITHOUT choices is answered by whatever comes back —
2900
3036
  // there is nothing to match against, and every reply is the answer.
2901
3037
  if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
@@ -3032,12 +3168,18 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3032
3168
  const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
3033
3169
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
3034
3170
  // Swap the marker for the resolved one so search reflects reality:
3035
- // `has::question:` should list what still needs answering, not everything
3036
- // ever asked. Removal last — a crash between the two leaves the question
3171
+ // `has::question: OR has::heavy_plus_sign:` should list what still needs
3172
+ // answering, not everything ever asked. Removal last — a crash between the two leaves the question
3037
3173
  // findable under BOTH, which is recoverable; the reverse loses it.
3038
3174
  try {
3039
3175
  await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
3040
- await reactionRemove(token, channelId, ts, ASK_MARKER, cookie);
3176
+ await reactionRemove(token, channelId, ts, ctx.seedMarker, cookie);
3177
+ } catch {
3178
+ // Cosmetic: the ✅ in the body is what actually marks it resolved.
3179
+ }
3180
+ try {
3181
+ // Asking for an explanation is over once it is answered.
3182
+ if (ctx.explain) await reactionRemove(token, channelId, ts, ASK_EXPLAIN_MARKER, cookie);
3041
3183
  } catch {
3042
3184
  // Cosmetic: the ✅ in the body is what actually marks it resolved.
3043
3185
  }
@@ -3102,6 +3244,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3102
3244
  await askEmit(token, ctx, r);
3103
3245
  process.exit(0);
3104
3246
  }
3247
+ // ℹ️ is never an answer: the question stays open, ✅ is not written, the
3248
+ // seeds stay. Only a caller that asked to hear about it stops here — and
3249
+ // only on the run that posted the thread note, so a `--timeout 0` poller is
3250
+ // woken once per question, not on every look.
3251
+ if (state.explainPosted && ctx.explainExit) {
3252
+ console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3253
+ if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3254
+ process.exit(ASK_EXIT_EXPLAIN);
3255
+ }
3105
3256
  // A free-text reply that picks nothing is delivered NOW, not held until the
3106
3257
  // deadline: it is usually an instruction or a question back, and the only
3107
3258
  // one who can act on it is the caller. Left unstamped — a pill pressed
@@ -3122,7 +3273,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3122
3273
  // but still reports a pill pressed after it.
3123
3274
  markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3124
3275
  if (ctx.json) {
3125
- await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3276
+ await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts), explain: state.explainNow });
3126
3277
  } else {
3127
3278
  console.log(unchosen.text);
3128
3279
  }
@@ -3167,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3167
3318
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3168
3319
  console.error(` ${shown}`);
3169
3320
  console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3170
- if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3321
+ if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3171
3322
  process.exit(ASK_EXIT_UNCHOSEN);
3172
3323
  }
3173
3324
  if (timeout === 0) {
@@ -3177,7 +3328,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3177
3328
  console.error(` ${shown}`);
3178
3329
  console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3179
3330
  }
3180
- if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3331
+ if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3181
3332
  process.exit(ASK_EXIT_TIMEOUT);
3182
3333
  }
3183
3334
 
@@ -3306,7 +3457,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3306
3457
  process.exit(ASK_EXIT_ERROR);
3307
3458
  }
3308
3459
  const lang = picked.lang;
3309
- const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
3460
+ const explain = args.explain ?? askExplainDefault();
3461
+ const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
3310
3462
  guardUrlBoundaries(message, args.allowUrlAdjacent);
3311
3463
 
3312
3464
  // Preview the destination's last message, exactly as `send` does — the gate's
@@ -3357,6 +3509,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3357
3509
  // shown as its own line rather than left to be inferred from the body.
3358
3510
  ` Answerable by: ${audienceLabel}`,
3359
3511
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3512
+ ...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
3360
3513
  ...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3361
3514
  ` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
3362
3515
  `--------------------------------────────────`,
@@ -3370,10 +3523,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3370
3523
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
3371
3524
 
3372
3525
  // The marker goes on LAST, after 1,2,3, because it doubles as the "other"
3373
- // choice the body lists under the numbered ones (people were already pressing
3374
- // ❓ for "none of these fit"). It is a reaction so
3375
- // `has::question:` lists every question the way `has::pushpin:` lists every
3376
- // todo. Body text cannot do that job: Slack's index splits on punctuation, so
3526
+ // choice the body lists under the numbered ones: ➕ (ASK_OTHER_MARKER). A
3527
+ // free-text question has no other choice and seeds ❓. It is a reaction so
3528
+ // `has::question: OR has::heavy_plus_sign:` lists every open question the
3529
+ // way `has::pushpin:` lists every todo — though ➕ is a common emoji, so that
3530
+ // search can also match unrelated messages (`slack ask --ls` reads the local
3531
+ // sent-log and is unaffected). Body text cannot do that job: Slack's index splits on punctuation, so
3377
3532
  // a `:question:` written in the text is indexed as the word "question" and
3378
3533
  // matches unrelated messages (measured). Only a real reaction is searchable.
3379
3534
  //
@@ -3384,14 +3539,15 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3384
3539
  // enough: measured on two real questions from the same build, one came back
3385
3540
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3386
3541
  // SLACK_REACTION_SEED_GAP_MS.
3542
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3387
3543
  await seedReactionsInOrder(
3388
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3544
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), seedMarker, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3389
3545
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3390
3546
  (name, e) => {
3391
3547
  const why = e instanceof Error ? e.message : String(e);
3392
3548
  // The marker is cosmetic + searchability; a keycap only costs the
3393
3549
  // answerer a trip to the emoji picker. Neither abandons a posted question.
3394
- if (name === ASK_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3550
+ if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3395
3551
  else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
3396
3552
  },
3397
3553
  );
@@ -3432,6 +3588,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3432
3588
  reactable,
3433
3589
  overflow,
3434
3590
  threadOnly,
3591
+ seedMarker,
3435
3592
  lang,
3436
3593
  audience,
3437
3594
  broadcast,
@@ -3441,6 +3598,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3441
3598
  shown,
3442
3599
  asBot: !!args.asBot,
3443
3600
  json: !!args.json,
3601
+ explain,
3602
+ explainExit: !!args.explainExit,
3444
3603
  };
3445
3604
  if (threadTs) ctx.threadParentTs = threadTs;
3446
3605
  if (cookie) ctx.cookie = cookie;
@@ -3522,7 +3681,7 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
3522
3681
  if (parsed.kind === "void") return "void";
3523
3682
  if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
3524
3683
  if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
3525
- const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
3684
+ const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
3526
3685
  if (got.threadTs) out.threadTs = got.threadTs;
3527
3686
  return out;
3528
3687
  }
@@ -3550,7 +3709,7 @@ async function askHasResponses(token: string, t: { channelId: string; ts: string
3550
3709
  }
3551
3710
 
3552
3711
  /** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
3553
- * open question THROUGH the ask builder, so pills, ❓, the instructions and the
3712
+ * open question THROUGH the ask builder, so pills, ➕, the instructions and the
3554
3713
  * body stay in the layout collect reads back (a free-hand `slack edit` is what
3555
3714
  * this replaces; `edit` now refuses ask messages without --force).
3556
3715
  *
@@ -3600,7 +3759,7 @@ async function cmdAskEdit(token: string, args: {
3600
3759
  const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
3601
3760
  const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
3602
3761
  const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
3603
- let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
3762
+ let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
3604
3763
  // An invalid-ballot notice still in force stays (it names the people who
3605
3764
  // have to fix their vote); it is only possible when the options did not change.
3606
3765
  const invalid = readInvalidNotice(oldText);
@@ -3633,16 +3792,32 @@ async function cmdAskEdit(token: string, args: {
3633
3792
  }
3634
3793
 
3635
3794
  await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3795
+ // The body was rebuilt, so its other line now says ➕ (or there is none, on
3796
+ // a free-text question) — the marker seed follows it.
3797
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3798
+ const oldSeed = askSeedMarker(oldText) ?? ASK_MARKER;
3636
3799
  if (choicesChanged) {
3637
- // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
3638
- for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3800
+ // Re-seed in order: 1..n then ➕ last, as a fresh question has them. The
3801
+ // old marker comes off as whatever it was seeded as (❓ on an old pill).
3802
+ const tail = (m: string) => old.explain ? [m, ASK_EXPLAIN_MARKER] : [m];
3803
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(oldSeed)]) {
3639
3804
  try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3640
3805
  }
3641
3806
  await seedReactionsInOrder(
3642
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3807
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
3643
3808
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3644
3809
  (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3645
3810
  );
3811
+ } else if (oldSeed !== seedMarker) {
3812
+ // An old ❓ pill edited without touching its choices: the body now lists
3813
+ // ➕, so swap the seed to match. Add before remove, as the ✅ swap does —
3814
+ // a failure in between leaves both, which still collects.
3815
+ try {
3816
+ await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
3817
+ await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
3818
+ } catch (e: unknown) {
3819
+ console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
3820
+ }
3646
3821
  }
3647
3822
  console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
3648
3823
  }
@@ -3765,7 +3940,10 @@ async function cmdAskPending(
3765
3940
  }
3766
3941
  const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3767
3942
  const pending: AskPendingItem[] = [];
3768
- const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3943
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
3944
+ /** Open questions someone pressed ℹ️ on — reported only: the thread note is
3945
+ * the waiting session's to post (`--pending` writes nothing). */
3946
+ const explainAsked: string[] = [];
3769
3947
  const problems: string[] = [];
3770
3948
  // The log is machine-wide, but a token reads one workspace. Asks recorded
3771
3949
  // under another workspace are counted and named, not probed with the wrong
@@ -3810,6 +3988,10 @@ async function cmdAskPending(
3810
3988
  const unchosen = poller.state.unchosen;
3811
3989
  if (poller.state.voided) { count.void++; continue; }
3812
3990
  const collect = askResumeCommand(link, asBot, after);
3991
+ if (poller.state.explainNow.length) {
3992
+ count.explain++;
3993
+ explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
3994
+ }
3813
3995
  const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3814
3996
  if (found) {
3815
3997
  pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
@@ -3858,6 +4040,7 @@ async function cmdAskPending(
3858
4040
  }
3859
4041
  }
3860
4042
  for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
4043
+ for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
3861
4044
  for (const [team, n] of otherTeams) {
3862
4045
  console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3863
4046
  }
@@ -3866,6 +4049,7 @@ async function cmdAskPending(
3866
4049
  (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3867
4050
  (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3868
4051
  (count.void ? `, ${count.void} 件は無効化済み` : "") +
4052
+ (count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
3869
4053
  ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3870
4054
  );
3871
4055
  }
@@ -3876,9 +4060,12 @@ interface AskVoidTarget {
3876
4060
  channelId: string;
3877
4061
  ts: string;
3878
4062
  parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
4063
+ /** The marker reaction it was seeded with (➕, or ❓ on an old pill / free
4064
+ * text) — what the void takes off. */
4065
+ seedMarker: string;
3879
4066
  }
3880
4067
 
3881
- /** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
4068
+ /** Void one question: the 🚫 text (body kept), our seeds and marker (➕, or ❓) off, 🚫 on.
3882
4069
  * The text is what marks it void; the reactions are cleanup + searchability,
3883
4070
  * so a failure there is reported and does not undo the void. */
3884
4071
  async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
@@ -3890,7 +4077,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
3890
4077
  console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3891
4078
  return false;
3892
4079
  }
3893
- for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
4080
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
3894
4081
  try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
3895
4082
  }
3896
4083
  try {
@@ -3913,7 +4100,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
3913
4100
  *
3914
4101
  * What voiding does, per question: the text becomes the 🚫 form (head, void
3915
4102
  * stamp with the reason, optional pointer to the replacement, the body); our
3916
- * seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
4103
+ * seeded pills and the marker (➕, or ❓) come off; a 🚫 reaction goes on. Pills someone
3917
4104
  * already pressed stay — Slack lets only their owner remove them — and are
3918
4105
  * never collected, because a void is never polled again.
3919
4106
  *
@@ -3946,7 +4133,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
3946
4133
  process.exit(ASK_EXIT_ERROR);
3947
4134
  }
3948
4135
  }
3949
- type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
4136
+ type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; seedMarker: string; pressed: string[]; unreadable?: boolean };
3950
4137
  const targets: Target[] = [];
3951
4138
  let refused = 0;
3952
4139
  for (const link of args.links) {
@@ -3984,14 +4171,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
3984
4171
  // Placeholders: only the COUNT is used — which of our keycap seeds to take off.
3985
4172
  reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
3986
4173
  overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
4174
+ // Our ℹ️ seed comes off too, if we put one on.
4175
+ explain: asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_EXPLAIN_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")),
3987
4176
  };
3988
4177
  unreadable = true;
3989
4178
  }
4179
+ // Our marker: what the body names, or — for an unreadable body — ➕ if we
4180
+ // seeded one, else ❓ (an old pill, or free text).
4181
+ const seedMarker = unreadable
4182
+ ? (asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_OTHER_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")) ? ASK_OTHER_MARKER : ASK_MARKER)
4183
+ : askSeedMarker(text) ?? ASK_MARKER;
3990
4184
  const pressed = asArray(msg.reactions).map(asRecord)
3991
4185
  .filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
3992
4186
  .filter((r) => asArray(r.users).some((u) => u !== self?.userId))
3993
4187
  .map((r) => `:${String(r.name)}:`);
3994
- targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
4188
+ targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
3995
4189
  }
3996
4190
  if (!targets.length) {
3997
4191
  console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
@@ -4009,7 +4203,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4009
4203
  ...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
4010
4204
  (t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
4011
4205
  (t.unreadable ? `\n ⚠ its layout no longer reads as an ask (edited by hand?) — voided from a best-effort read: question and the rest of the text kept as they are` : "")),
4012
- ` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
4206
+ ` Each becomes 🚫 (body kept, options and instructions removed); our pills and marker (➕, or ❓) come off; a 🚫 reaction is added.`,
4013
4207
  ...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
4014
4208
  `----------------------------------------------`,
4015
4209
  ]);
@@ -4030,7 +4224,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4030
4224
  * file to go stale, lose, or disagree with Slack, and any machine holding the
4031
4225
  * permalink can collect — at the cost of the body being a parseable format
4032
4226
  * (`askBuildText` ⇄ `askParseMessage`). */
4033
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
4227
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean }): Promise<void> {
4034
4228
  const url = parseSlackPermalink(args.link);
4035
4229
  let channelId: string;
4036
4230
  let ts: string;
@@ -4184,7 +4378,7 @@ function askWaitCtxFrom(
4184
4378
  q: AskReadBack,
4185
4379
  parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4186
4380
  loc: { channelId: string; ts: string; threadTs?: string | undefined },
4187
- o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4381
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined; explainExit?: boolean | undefined },
4188
4382
  ): AskWaitCtx {
4189
4383
  const ctx: AskWaitCtx = {
4190
4384
  channelId: loc.channelId,
@@ -4193,6 +4387,7 @@ function askWaitCtxFrom(
4193
4387
  reactable: parsed.reactable,
4194
4388
  overflow: parsed.overflow,
4195
4389
  threadOnly: parsed.threadOnly,
4390
+ seedMarker: askSeedMarker(q.text) ?? ASK_MARKER,
4196
4391
  lang: parsed.lang,
4197
4392
  audience: q.audience,
4198
4393
  broadcast: q.broadcast,
@@ -4202,6 +4397,8 @@ function askWaitCtxFrom(
4202
4397
  shown: o.link,
4203
4398
  asBot: o.asBot,
4204
4399
  json: o.json,
4400
+ explain: parsed.explain,
4401
+ explainExit: !!o.explainExit,
4205
4402
  };
4206
4403
  if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4207
4404
  if (o.cookie) ctx.cookie = o.cookie;
@@ -5745,8 +5942,8 @@ async function main(): Promise<void> {
5745
5942
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5746
5943
  .option("body", { type: "string", describe: "Extra context shown under the question" })
5747
5944
  .option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
5748
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting." })
5749
- .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
5945
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
5946
+ .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
5750
5947
  .option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
5751
5948
  .option("void", { type: "string", array: true, describe: "無効化: void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
5752
5949
  .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." })
@@ -5760,6 +5957,8 @@ async function main(): Promise<void> {
5760
5957
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5761
5958
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5762
5959
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5960
+ .option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ➕): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
5961
+ .option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
5763
5962
  .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." })
5764
5963
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5765
5964
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
@@ -5884,6 +6083,8 @@ async function main(): Promise<void> {
5884
6083
  argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
5885
6084
  if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
5886
6085
  if (!argv.lang) argv.lang = t.parsed.lang;
6086
+ // A re-ask offers what the old question offered, unless told otherwise.
6087
+ if (argv.explain === undefined && t.parsed.explain) argv.explain = true;
5887
6088
  argv.void = undefined;
5888
6089
  }
5889
6090
  if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
@@ -5973,7 +6174,8 @@ async function main(): Promise<void> {
5973
6174
  waitCookie = ck(argv as W);
5974
6175
  }
5975
6176
  try {
5976
- const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6177
+ const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6178
+ if (argv["explain-exit"]) a.explainExit = true;
5977
6179
  if (waitCookie) a.cookie = waitCookie;
5978
6180
  if (argv.after) {
5979
6181
  const after = String(argv.after);
@@ -6011,6 +6213,8 @@ async function main(): Promise<void> {
6011
6213
  if (argv.lang) args.lang = String(argv.lang);
6012
6214
  if (argv.wait) args.wait = true;
6013
6215
  if (argv.json) args.json = true;
6216
+ if (typeof argv.explain === "boolean") args.explain = argv.explain;
6217
+ if (argv["explain-exit"]) args.explainExit = true;
6014
6218
  if (voidAfter) args.voidAfter = voidAfter;
6015
6219
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
6016
6220
  if (argv["user-id"]) args.userId = argv["user-id"];