allaboard 0.5.0 → 0.6.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.
Files changed (53) hide show
  1. package/README.md +9 -7
  2. package/dist/agent/claude/backend.js +73 -2
  3. package/dist/agent/claude/capabilities.js +4 -0
  4. package/dist/agent/claude/module.js +55 -0
  5. package/dist/agent/claude/translate.js +6 -2
  6. package/dist/agent/claude/trust.js +22 -0
  7. package/dist/chat/seam.js +3 -0
  8. package/dist/chat/slack/app/app.js +52 -4
  9. package/dist/chat/slack/app/delete-thread.js +90 -0
  10. package/dist/chat/slack/app/guards.js +44 -0
  11. package/dist/chat/slack/app/home-controls.js +8 -1
  12. package/dist/chat/slack/channels.js +23 -5
  13. package/dist/chat/slack/delete-thread.js +37 -0
  14. package/dist/chat/slack/delete.js +13 -7
  15. package/dist/chat/slack/home.js +194 -35
  16. package/dist/chat/slack/module.js +64 -14
  17. package/dist/chat/slack/onboarding.js +159 -0
  18. package/dist/chat/slack/reply/blocks.js +27 -11
  19. package/dist/chat/slack/reply/sinks.js +3 -1
  20. package/dist/chat/slack/tag.js +317 -0
  21. package/dist/chat/slack/texts.js +8 -0
  22. package/dist/chat/slack/thread.js +62 -2
  23. package/dist/chat/slack/tokens.js +15 -0
  24. package/dist/cli.js +43 -4
  25. package/dist/core/cleanup.js +66 -3
  26. package/dist/core/commands.js +40 -0
  27. package/dist/core/config.js +19 -11
  28. package/dist/core/files/listing.js +19 -0
  29. package/dist/core/flows/bind.js +29 -6
  30. package/dist/core/flows/flows.js +2 -0
  31. package/dist/core/flows/gone.js +39 -0
  32. package/dist/core/flows/inbound.js +3 -0
  33. package/dist/core/flows/messages.js +4 -2
  34. package/dist/core/flows/status.js +98 -48
  35. package/dist/core/flows/words.js +80 -1
  36. package/dist/core/guide.js +2 -0
  37. package/dist/core/port/cleanup.js +1 -0
  38. package/dist/core/port.js +1 -0
  39. package/dist/core/sessions/directory.js +47 -8
  40. package/dist/core/sessions/manager.js +32 -17
  41. package/dist/core/sessions/quote.js +57 -0
  42. package/dist/core/sessions/session.js +324 -35
  43. package/dist/core/sessions/title.js +98 -0
  44. package/dist/core/state.js +25 -0
  45. package/dist/core/texts.js +58 -3
  46. package/dist/core/wizard.js +306 -0
  47. package/dist/daemon.js +23 -11
  48. package/dist/host/program.js +22 -7
  49. package/dist/host/prompter.js +61 -0
  50. package/dist/module-setup.js +6 -0
  51. package/dist/version.js +4 -0
  52. package/package.json +5 -2
  53. package/slack-app-manifest.json +2 -1
@@ -9,13 +9,17 @@ export const CLEAN_EVERY_SECONDS = 6 * 60 * 60;
9
9
  * none of the bound channels (a channel it gave no answer about is not one it found), nothing is
10
10
  * removed: that is what the token of another workspace, or an app removed from every channel,
11
11
  * looks like, and forgetting every binding would be the wrong repair. `chat` is the chat's
12
- * name, for the log lines.
12
+ * name, for the log lines. Once `stopping` says a stop has begun no further channel is asked
13
+ * about and nothing is forgotten: the channels left for the next start.
13
14
  */
14
- export async function forgetGoneChannels(lookup, state, live, chat, log = logger) {
15
+ export async function forgetGoneChannels(lookup, state, live, chat, log = logger, stopping = () => false) {
15
16
  const bound = state.channels();
16
17
  const gone = [];
17
18
  const found = [];
18
19
  for (const channelId of bound) {
20
+ // One call to the chat per channel: a stop that begins meanwhile waits for one, not for all.
21
+ if (stopping())
22
+ return { forgotten: [], found };
19
23
  let answer;
20
24
  try {
21
25
  answer = await lookup(channelId);
@@ -112,7 +116,8 @@ export async function clean(lookup, state, options) {
112
116
  const log = options.logger ?? logger;
113
117
  let found = [];
114
118
  try {
115
- found = (await forgetGoneChannels(lookup, state, live, options.chat, log)).found;
119
+ found = (await forgetGoneChannels(lookup, state, live, options.chat, log, options.stopping))
120
+ .found;
116
121
  }
117
122
  catch (error) {
118
123
  log.warning(`could not check the bound channels: ${errorName(error)}`);
@@ -123,6 +128,10 @@ export async function clean(lookup, state, options) {
123
128
  catch (error) {
124
129
  log.warning(`could not check the threads: ${errorName(error)}`);
125
130
  }
131
+ // A stop closes the sessions after this pass: the keep set `live` gives would then be empty,
132
+ // and a thread whose session id is not on disk yet would be pruned. The next start prunes.
133
+ if (options.stopping?.())
134
+ return;
126
135
  try {
127
136
  const folders = new Set(state
128
137
  .threads()
@@ -143,3 +152,57 @@ export async function clean(lookup, state, options) {
143
152
  log.warning(`could not prune stale threads: ${errorName(error)}`);
144
153
  }
145
154
  }
155
+ /**
156
+ * The only way a pass of `clean` starts: the one at start, the scheduled ones and the owner's
157
+ * Refresh all ask here. One pass runs at a time; a request made while one runs shares the single
158
+ * pass that follows it (the first pass may have read a channel before the owner's change), so any
159
+ * number of clicks cost at most one more. Once `stopping` says a stop has begun a request does
160
+ * nothing, and neither does the pass queued behind a running one.
161
+ */
162
+ export class Cleanups {
163
+ #run;
164
+ #stopping;
165
+ #log;
166
+ #running = null;
167
+ #next = null;
168
+ constructor(run, stopping, log = logger) {
169
+ this.#run = run;
170
+ this.#stopping = stopping;
171
+ this.#log = log;
172
+ }
173
+ /** Resolves once a pass that began after this call has ended, or at once during a stop. Never rejects. */
174
+ request() {
175
+ if (this.#stopping())
176
+ return Promise.resolve();
177
+ // A pass queued behind the running one is joined before the running one is looked at: the
178
+ // running pass clears `#running` a turn before the queued one starts.
179
+ if (this.#next !== null)
180
+ return this.#next;
181
+ if (this.#running === null)
182
+ return this.#launch();
183
+ this.#next ??= this.#running.then(() => {
184
+ this.#next = null;
185
+ return this.#stopping() ? undefined : this.#launch();
186
+ });
187
+ return this.#next;
188
+ }
189
+ /** Resolves once the pass running and the one queued behind it have ended; at once with none. */
190
+ settled() {
191
+ return this.#next ?? this.#running ?? Promise.resolve();
192
+ }
193
+ #launch() {
194
+ const pass = (async () => {
195
+ try {
196
+ await this.#run();
197
+ }
198
+ catch (error) {
199
+ this.#log.warning(`the cleanup failed: ${errorName(error)}`);
200
+ }
201
+ finally {
202
+ this.#running = null;
203
+ }
204
+ })();
205
+ this.#running = pass;
206
+ return pass;
207
+ }
208
+ }
@@ -1,4 +1,5 @@
1
1
  import { compareCodePoints, PY_SPACE, strip } from "../text.js";
2
+ import { limitsPage } from "../version.js";
2
3
  import * as texts from "./texts.js";
3
4
  /** The word each of the daemon's own commands is typed as; a new kind of `Word` fails to compile here. */
4
5
  export const WORD = {
@@ -10,9 +11,17 @@ export const WORD = {
10
11
  stop: "stop",
11
12
  resume: "resume",
12
13
  open: "open",
14
+ tag: "tag",
15
+ untag: "untag",
16
+ "delete-thread": "delete-thread",
13
17
  allaboard: "allaboard",
14
18
  };
15
19
  export const DESCRIPTION_LIMIT = 100;
20
+ /**
21
+ * A thread's tag: 1 to 40 letters, digits, `.`, `_` or `-`, kept as typed. One rule for what
22
+ * `!tag` accepts and for what a chat reads back as a tag.
23
+ */
24
+ export const TAG_NAME = /^[\p{L}\p{N}._-]{1,40}$/u;
16
25
  // Python's whitespace, which `strip` and `split` follow, is not JavaScript's `\s` (`text.ts`).
17
26
  const TRAILING = new RegExp(`[${PY_SPACE}]+$`);
18
27
  const STARTS_WITH_SPACE = new RegExp(`^[${PY_SPACE}]`);
@@ -64,6 +73,16 @@ export function parseBang(text) {
64
73
  return { kind: "resume", target: rest };
65
74
  case "open":
66
75
  return { kind: "open", target: rest };
76
+ case "tag":
77
+ // Alone, or with a name the rule refuses, it is mistyped: the help shows how it is written.
78
+ return TAG_NAME.test(rest) ? { kind: "tag", name: rest } : { kind: "invalid" };
79
+ case "untag":
80
+ // A thread has one tag, so `!untag devops` means `!untag`: what follows the word says
81
+ // nothing more, and a word the owner typed must not reach the agent as a prompt.
82
+ return { kind: "untag" };
83
+ case "delete-thread":
84
+ // Anything after it is a mistake: the help shows how it is written, and nothing is deleted.
85
+ return rest === "" ? { kind: "delete-thread" } : { kind: "invalid" };
67
86
  case "allaboard":
68
87
  switch (rest.toLowerCase()) {
69
88
  case "":
@@ -134,6 +153,27 @@ export function hostOnly(command, names, agentCommands = true) {
134
153
  }
135
154
  return null;
136
155
  }
156
+ /**
157
+ * The answer to a command of the agent's terminal that a session from the chat does not offer
158
+ * (`notOffered`, the agent's words), with what to do instead, or null: for any other name, for one
159
+ * the session's own list (`offered`) has, and for every one when the agent has no commands of its
160
+ * own (`agentCommands` false). The caller sends that answer and nothing to the agent.
161
+ */
162
+ export function notOfferedAnswer(command, offered, notOffered, names, agentCommands = true) {
163
+ if (!agentCommands)
164
+ return null;
165
+ const word = command.text.split(" ", 1)[0].toLowerCase();
166
+ if (!Object.hasOwn(notOffered, word))
167
+ return null;
168
+ if (offered.some((listed) => listed.name.toLowerCase() === word))
169
+ return null;
170
+ const instead = notOffered[word] ?? null;
171
+ return [
172
+ texts.fill(texts.named(texts.NOT_OFFERED, names), { command: word }),
173
+ ...(instead === null ? [] : [instead]),
174
+ texts.fill(texts.NOT_OFFERED_SEE, { link: limitsPage() }),
175
+ ].join(" ");
176
+ }
137
177
  /**
138
178
  * The command names a thread refuses, lowercase: the agent's `newSession` names (one thread is
139
179
  * one session, and a thread is also the only place a session's commands are listed; they are
@@ -84,6 +84,24 @@ function isDirectory(path) {
84
84
  throw error;
85
85
  }
86
86
  }
87
+ /**
88
+ * The directory `written` names as `ALLOWED_ROOT`, resolved; throws `ConfigError` when it is not an
89
+ * absolute path (or one starting with `~/`) or does not name a directory. The one rule, for the
90
+ * daemon's start and for `allaboard setup`.
91
+ */
92
+ export function allowedRootOf(written) {
93
+ const expanded = expandUser(written);
94
+ // A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
95
+ // `/` under launchd.
96
+ if (!isAbsolute(expanded)) {
97
+ throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
98
+ }
99
+ const root = resolvePath(expanded);
100
+ if (!isDirectory(root)) {
101
+ throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
102
+ }
103
+ return root;
104
+ }
87
105
  /** The values of `names` that `values` holds, readable by lookup and shown by nothing. */
88
106
  function hiddenVariables(names, values) {
89
107
  const hidden = Object.create(null);
@@ -150,17 +168,7 @@ export function loadConfig(modules, configDir, env = process.env) {
150
168
  if (missing.length > 0) {
151
169
  throw new ConfigError(`missing in ${envPath}: ${missing.join(", ")}`);
152
170
  }
153
- const written = values.ALLOWED_ROOT;
154
- const expanded = expandUser(written);
155
- // A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
156
- // `/` under launchd.
157
- if (!isAbsolute(expanded)) {
158
- throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
159
- }
160
- const root = resolvePath(expanded);
161
- if (!isDirectory(root)) {
162
- throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
163
- }
171
+ const root = allowedRootOf(values.ALLOWED_ROOT);
164
172
  return Object.defineProperties({ agent, chat, allowedRoot: root, configDir }, {
165
173
  agentVariables: {
166
174
  value: hiddenVariables([...agentModule.required, ...agentModule.optional], values),
@@ -124,6 +124,25 @@ export async function repositoriesOf(folder, lookup) {
124
124
  }
125
125
  return [...found.values()];
126
126
  }
127
+ /**
128
+ * The usable repositories found at most two levels below `folder` that only want the owner's
129
+ * trust (`untrusted`, the agent's `untrustedRepository`), one per key and sorted by it: the ones
130
+ * `repositoriesOf` leaves out for that. None when the repository holding `folder` is usable
131
+ * (`trusted`), since `repositoriesOf` looks no further then.
132
+ */
133
+ export async function untrustedRepositories(folder, trusted, untrusted) {
134
+ if (usable(await trusted(folder)) !== null)
135
+ return [];
136
+ const below = await repositoryFolders(folder);
137
+ const looked = await Promise.all(below.map((candidate) => untrusted(candidate)));
138
+ // Two worktrees of one checkout share its key, and one trust covers both.
139
+ const found = new Map();
140
+ for (const repository of looked.map(usable)) {
141
+ if (repository !== null)
142
+ found.set(repository.key, repository);
143
+ }
144
+ return [...found.values()].sort((a, b) => compareCodePoints(a.key, b.key));
145
+ }
127
146
  /**
128
147
  * The regular files under `root` as paths from it, the shallower first: a symlink is neither
129
148
  * followed nor listed, no `.git` is entered, nor any folder in `skip` (paths). Stops when
@@ -1,7 +1,9 @@
1
1
  import { compareCodePoints } from "../../text.js";
2
- import { resolveDirectory } from "../sessions/directory.js";
2
+ import { errorName } from "../../values.js";
3
+ import { resolveDirectory, untrustedNotice } from "../sessions/directory.js";
3
4
  import * as texts from "../texts.js";
4
5
  import { fill, named } from "../texts.js";
6
+ import { isCancelled, logger } from "./talk.js";
5
7
  /**
6
8
  * The answer to a bind: what keeps a session from starting in the folder, if anything
7
9
  * (`BIND_UNAVAILABLE` keeps priority: what would stop every session there matters more than
@@ -21,16 +23,35 @@ export function boundText(directory, unavailable, oldFolders, inNotice) {
21
23
  const old = oldFolders.map((folder) => `\`${inNotice(folder)}\``).join(", ");
22
24
  return fill(texts.BIND_OK_ELSEWHERE, { directory: inNotice(directory), old });
23
25
  }
26
+ // What the owner is told for each reason a typed path binds nothing.
27
+ const REFUSALS = {
28
+ missing: texts.BIND_MISSING,
29
+ file: texts.BIND_FILE,
30
+ outside: texts.BIND_OUTSIDE,
31
+ };
24
32
  export function binding(deps, talk) {
25
33
  const { agent, chat, sessions, state, names, allowedRoot } = deps;
26
34
  const inNotice = (text) => chat.escape(text, "notice");
27
35
  /** The folder a typed path or a pick names, inside the allowed root; null, told, otherwise. */
28
36
  const folderNamed = async (channel, path) => {
29
- const directory = resolveDirectory(path, allowedRoot);
30
- if (directory === null) {
31
- await talk.inChannel(channel, fill(texts.BIND_OUTSIDE, { path: inNotice(path), root: inNotice(allowedRoot) }));
37
+ const resolved = resolveDirectory(path, allowedRoot);
38
+ if ("directory" in resolved)
39
+ return resolved.directory;
40
+ const refusal = REFUSALS[resolved.refused];
41
+ await talk.inChannel(channel, fill(refusal, { path: inNotice(path), root: inNotice(allowedRoot) }));
42
+ return null;
43
+ };
44
+ /** The repositories below `directory` still to trust; a failed lookup is logged and names none. */
45
+ const untrusted = async (directory) => {
46
+ try {
47
+ return await untrustedNotice(agent, directory, names, inNotice);
48
+ }
49
+ catch (error) {
50
+ if (isCancelled(error))
51
+ throw error;
52
+ logger.warning(`could not look for repositories to trust: ${errorName(error)}`);
53
+ return [];
32
54
  }
33
- return directory;
34
55
  };
35
56
  // Thread entries keep their own folder either side of the bind (`StateStore.bind`), so the
36
57
  // old folders can be read now: those of the channel's threads that differ from the new one.
@@ -41,7 +62,9 @@ export function binding(deps, talk) {
41
62
  const oldFolders = [
42
63
  ...new Set(threads.map((thread) => thread.directory).filter((old) => old !== directory)),
43
64
  ].sort(compareCodePoints);
44
- await talk.inChannel(channel, boundText(directory, unavailable, oldFolders, inNotice));
65
+ // A folder no session can start in names no repository below it: that comes first.
66
+ const below = unavailable === null ? await untrusted(directory) : [];
67
+ await talk.inChannel(channel, [boundText(directory, unavailable, oldFolders, inNotice), ...below].join("\n"));
45
68
  };
46
69
  return {
47
70
  async to(channel, path) {
@@ -1,4 +1,5 @@
1
1
  import { asking } from "./asking.js";
2
+ import { rootGone } from "./gone.js";
2
3
  import { Inbound } from "./inbound.js";
3
4
  import { lists } from "./lists.js";
4
5
  import { messages } from "./messages.js";
@@ -30,6 +31,7 @@ export function buildFlows(deps) {
30
31
  answer: (ref, requestId, answer, message) => answers.answer(ref, requestId, answer, message),
31
32
  picked: (pick) => (pick.type === "update" ? updatePicked(pick) : picks.picked(pick)),
32
33
  unbound: (channel) => talk.unbound(channel),
34
+ rootGone: rootGone(deps),
33
35
  }),
34
36
  };
35
37
  }
@@ -0,0 +1,39 @@
1
+ import { errorName } from "../../values.js";
2
+ import { logger } from "./talk.js";
3
+ /**
4
+ * The handler of a deleted first message: holds the thread, closes its session and removes its
5
+ * entry, then frees the hold it took. Does nothing for a thread already held (a delete under way)
6
+ * or in use, or while the daemon stops; the cleanup forgets those at its next pass.
7
+ */
8
+ export function rootGone(deps) {
9
+ const { sessions, state, stopped } = deps;
10
+ return async ({ channel, thread }) => {
11
+ // Held by a delete already under way (`!delete-thread`, the Home tab's Delete): that delete
12
+ // removes the entry itself, and the event is its own deletion coming back. The hold is its
13
+ // to free, never this call's.
14
+ if (sessions.held(channel, thread))
15
+ return;
16
+ // The thread is held until the entry is gone: a message sent in it meanwhile finds no
17
+ // session and none is rebuilt from the entry (`SessionManager.hold`, as a delete does). A
18
+ // refusal took no hold, so nothing is freed for it.
19
+ if (!sessions.hold(channel, thread)) {
20
+ // A turn is running, or the daemon is stopping. The cleanup forgets the thread at its next
21
+ // pass, since a tombstoned root is gone for it too.
22
+ logger.info(stopped.aborted
23
+ ? `left ${channel}/${thread} to the next start's cleanup: its first message was deleted`
24
+ : `left ${channel}/${thread} to the cleanup: its first message was deleted while it works`);
25
+ return;
26
+ }
27
+ try {
28
+ await sessions.closeHeld(channel, thread);
29
+ state.removeThread(channel, thread);
30
+ logger.info(`forgot ${channel}/${thread}: its first message was deleted`);
31
+ }
32
+ catch (error) {
33
+ logger.warning(`could not forget ${channel}/${thread}: ${errorName(error)}`);
34
+ }
35
+ finally {
36
+ sessions.free(channel, thread);
37
+ }
38
+ };
39
+ }
@@ -15,6 +15,9 @@ export class Inbound {
15
15
  unbound(channel) {
16
16
  return this.#flow("unbound")(channel);
17
17
  }
18
+ rootGone(ref) {
19
+ return this.#flow("rootGone")(ref);
20
+ }
18
21
  #flow(name) {
19
22
  const flow = this.#flows[name];
20
23
  if (flow === undefined)
@@ -10,7 +10,7 @@
10
10
  * hands the messages of a thread over in the order its listeners were called, whatever its
11
11
  * checks take, with nothing awaited between its last check and the call.
12
12
  */
13
- import { hostOnly, parseBang, refusedInThread, } from "../commands.js";
13
+ import { hostOnly, notOfferedAnswer, parseBang, refusedInThread, } from "../commands.js";
14
14
  import { SessionClosed, SessionGone } from "../sessions/errors.js";
15
15
  import { Mutex } from "../sessions/tasks.js";
16
16
  import * as texts from "../texts.js";
@@ -245,7 +245,9 @@ export function messages(deps, talk, others) {
245
245
  return;
246
246
  }
247
247
  if (command !== null) {
248
- const answer = hostOnly(command, names, agent.capabilities.commands);
248
+ const answer = hostOnly(command, names, agent.capabilities.commands) ??
249
+ // A session not connected yet lists nothing: the agent's own list is known once it is.
250
+ notOfferedAnswer(command, session?.commands ?? [], agent.words.notOffered, names, agent.capabilities.commands);
249
251
  if (answer !== null) {
250
252
  // Answered where a word is: under the owner's message in a session's thread, for the
251
253
  // owner alone, and as a post in the channel anywhere else. No session starts.
@@ -3,7 +3,12 @@ import { oneLine, TITLE_LIMIT } from "../reply/words.js";
3
3
  import * as texts from "../texts.js";
4
4
  import { fill } from "../texts.js";
5
5
  import { isCancelled, logger } from "./talk.js";
6
- /** How long ago, in one unit, as a channel's `!status` says it of an idle session. */
6
+ /**
7
+ * The rows one answer shows, newest first; the older ones are counted. A notice is one line of
8
+ * small text with a limit, and each row carries a link.
9
+ */
10
+ export const STATUS_ROWS = 10;
11
+ /** How long ago, in one unit, as a channel's `!status` says it of a session at rest. */
7
12
  export function ago(seconds) {
8
13
  const whole = Math.trunc(seconds);
9
14
  if (whole < 60)
@@ -14,40 +19,88 @@ export function ago(seconds) {
14
19
  return `${Math.floor(whole / 3600)}h ago`;
15
20
  return `${Math.floor(whole / 86400)}d ago`;
16
21
  }
22
+ /** A status that says how a turn ended, or null for any other. */
23
+ function outcome(status) {
24
+ return status === "done" || status === "error" ? status : null;
25
+ }
17
26
  export function channelStatus(deps, talk) {
18
- const { state, sessions } = deps;
19
- async function row(channel, channelDirectory, session, known) {
20
- const stored = state.thread(channel, session.threadId);
21
- const info = stored === null ? undefined : known.get(String(stored.sessionId));
27
+ const { chat, state, sessions } = deps;
28
+ async function row(channel, channelDirectory, shown) {
29
+ const { thread, stored, live, info } = shown;
22
30
  const title = info === undefined ? "" : oneLine(info.title, TITLE_LIMIT);
23
- const linked = await talk.referenceLink({ channel, thread: session.threadId }, title);
31
+ const label = chat.escape(title, "notice") || texts.STATUS_CHANNEL_SESSION;
32
+ const linked = await talk.noticeLink({ channel, thread }, label);
33
+ let status;
24
34
  let activity;
25
- if (session.waitingForOwner) {
35
+ // How long the session has rested: said of one at rest alone.
36
+ let since = "";
37
+ if (live?.waitingForOwner) {
38
+ status = "waiting";
26
39
  activity = texts.STATUS_CHANNEL_WAITING;
27
40
  }
28
- else if (session.busy || session.runningKinds) {
29
- // A session with only a background task running is not idle either.
41
+ else if (live?.busy || live?.runningKinds) {
42
+ // A session with only a background task running is not at rest either.
43
+ status = "working";
30
44
  activity = texts.STATUS_CHANNEL_BUSY;
31
45
  }
32
46
  else {
33
- activity = texts.STATUS_CHANNEL_IDLE;
47
+ // At rest, live or closed, the row says how its last turn ended, as the root's mark does.
48
+ status = outcome(stored.status) ?? outcome(stored.ended) ?? "done";
34
49
  if (info?.lastModified) {
35
50
  // In milliseconds, and by now the time of the session's last message (dated below).
36
- const since = ago(deps.now() - info.lastModified / 1000);
37
- activity += fill(texts.STATUS_CHANNEL_SINCE, { ago: since });
51
+ since = fill(texts.STATUS_CHANNEL_SINCE, {
52
+ ago: ago(deps.now() - info.lastModified / 1000),
53
+ });
38
54
  }
55
+ activity = texts.STATUS_CHANNEL_IDLE + since;
39
56
  }
40
- let line = fill(texts.STATUS_CHANNEL_ROW, { link: linked, activity });
41
- if (session.runningKinds) {
42
- line += ` · ${fill(texts.RUNNING, { counts: session.runningKinds })}`;
57
+ const mark = chat.capabilities.reactions ? chat.statusMark?.(status) : undefined;
58
+ let line = mark === undefined
59
+ ? fill(texts.STATUS_CHANNEL_ROW, { link: linked, activity })
60
+ : fill(texts.STATUS_CHANNEL_MARKED_ROW, { mark, link: linked, since });
61
+ if (live?.runningKinds) {
62
+ line += ` · ${fill(texts.RUNNING, { counts: live.runningKinds })}`;
43
63
  }
44
- if (session.bypass)
64
+ if (live === null ? stored.bypass === true : live.bypass)
45
65
  line += texts.STATUS_CHANNEL_BYPASS;
46
- if (session.directory !== channelDirectory) {
47
- line += fill(texts.STATUS_CHANNEL_FOLDER, { directory: session.directory });
66
+ const directory = live?.directory ?? stored.directory;
67
+ if (directory !== channelDirectory) {
68
+ line += fill(texts.STATUS_CHANNEL_FOLDER, { directory });
48
69
  }
49
70
  return line;
50
71
  }
72
+ /**
73
+ * The title and the time of the last message of each session `threads` hold, by session id.
74
+ * Dated by the session's last message, not by its file: Claude Code writes entries with no
75
+ * timestamp to a transcript when it connects the session again, so a session idle for 16
76
+ * minutes read `just now` (seen 2026-10-07). A folder that cannot be listed is logged and its
77
+ * sessions shown untitled.
78
+ */
79
+ async function listed(threads) {
80
+ const known = new Map();
81
+ const byDirectory = new Map();
82
+ for (const [, stored] of threads) {
83
+ if (stored.sessionId === null)
84
+ continue;
85
+ const ids = byDirectory.get(stored.directory) ?? new Set();
86
+ ids.add(stored.sessionId);
87
+ byDirectory.set(stored.directory, ids);
88
+ }
89
+ for (const [directory, ids] of byDirectory) {
90
+ try {
91
+ const found = await sessions.sessionsIn(directory);
92
+ const dated = await sessions.dated(directory, found.filter((session) => ids.has(session.id)));
93
+ for (const session of dated)
94
+ known.set(session.id, session);
95
+ }
96
+ catch (error) {
97
+ if (isCancelled(error))
98
+ throw error;
99
+ logger.warning(`could not list a folder's sessions: ${errorName(error)}`);
100
+ }
101
+ }
102
+ return known;
103
+ }
51
104
  return async (channel) => {
52
105
  const record = state.channel(channel);
53
106
  if (record === null) {
@@ -55,43 +108,40 @@ export function channelStatus(deps, talk) {
55
108
  return;
56
109
  }
57
110
  const lines = [fill(texts.STATUS_CHANNEL_HEADER, { directory: record.directory })];
58
- const live = sessions.sessionsOf(channel);
59
- if (live.length === 0) {
111
+ const live = new Map(sessions.sessionsOf(channel).map((session) => [session.threadId, session]));
112
+ // Every thread that holds a session, as the session index lists them, and a live one whose
113
+ // session has no id yet (its setup is still open): a restart closes every session, and an
114
+ // answer of live ones alone was empty right after one (the owner, 2026-10-19).
115
+ const threads = state
116
+ .threads()
117
+ .filter(([id, thread, stored]) => id === channel && (stored.sessionId !== null || live.has(thread)))
118
+ .map(([, thread, stored]) => [thread, stored]);
119
+ if (threads.length === 0) {
60
120
  lines.push(texts.STATUS_CHANNEL_EMPTY);
61
121
  }
62
122
  else {
63
- // What tells one thread from another (issue #76): the title the agent gave its session,
64
- // as the Home tab and a restart's notice name it, and the time of its last message. Not
65
- // the time of its file, which the listing gives: Claude Code writes entries with no
66
- // timestamp to a transcript when it connects the session again, so a session idle for 16
67
- // minutes read `just now` (seen 2026-10-07). Only the sessions the answer shows are
68
- // dated, since dating reads their files.
69
- const shown = new Set();
70
- for (const session of live) {
71
- const stored = state.thread(channel, session.threadId);
72
- if (stored?.sessionId)
73
- shown.add(stored.sessionId);
74
- }
75
- const known = new Map();
76
- for (const directory of new Set(live.map((session) => session.directory))) {
77
- try {
78
- const listed = await sessions.sessionsIn(directory);
79
- const dated = await sessions.dated(directory, listed.filter((session) => shown.has(session.id)));
80
- for (const session of dated)
81
- known.set(session.id, session);
82
- }
83
- catch (error) {
84
- if (isCancelled(error))
85
- throw error;
86
- logger.warning(`could not list a folder's sessions: ${errorName(error)}`);
87
- }
123
+ const known = await listed(threads);
124
+ const all = threads.map(([thread, stored]) => {
125
+ const info = stored.sessionId === null ? undefined : known.get(stored.sessionId);
126
+ const opened = chat.openedAt({ channel, thread }) ?? 0;
127
+ return {
128
+ thread,
129
+ stored,
130
+ live: live.get(thread) ?? null,
131
+ info,
132
+ when: info?.lastModified ? info.lastModified / 1000 : opened,
133
+ };
134
+ });
135
+ all.sort((a, b) => b.when - a.when);
136
+ const shown = all.slice(0, STATUS_ROWS);
137
+ lines.push(...(await Promise.all(shown.map((s) => row(channel, record.directory, s)))));
138
+ if (all.length > shown.length) {
139
+ lines.push(fill(texts.STATUS_CHANNEL_MORE, { count: all.length - shown.length }));
88
140
  }
89
- lines.push(...(await Promise.all(live.map((session) => row(channel, record.directory, session, known)))));
90
141
  }
91
- await talk.say(channel, null, lines.join("\n"));
142
+ await talk.inChannel(channel, lines.join("\n"));
92
143
  const waits = await talk.restartWaits(channel);
93
144
  if (waits) {
94
- // A notice of its own: the rows are written for a notice, and the status above is a reference.
95
145
  await talk.inChannel(channel, [texts.RESTART_WAITS_HEADER, waits].join("\n"));
96
146
  }
97
147
  };