allaboard 0.0.0-stage → 0.4.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 duqaXxX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # allaboard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your Claude Code workspace in Slack.
4
+
5
+ All your projects, all your sessions and all your Macs, in the one app you already keep open.
6
+ Connect every Mac you work on and every Claude Code account you use to the same Slack, and each
7
+ of them is one message away. Each project is a channel, each session is a thread: follow the work
8
+ as Claude writes it, approve a command with one tap, and pick any session up again days later.
9
+ Claude Code keeps running on each Mac, with its own files and its own setup.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install -g allaboard
15
+ ```
16
+
17
+ The [setup guide](https://github.com/duqaXxX/allaboard/blob/v0.4.0/docs/setup.md) says which
18
+ systems this version supports, and takes you through
19
+ [creating the Slack app](https://github.com/duqaXxX/allaboard/blob/v0.4.0/docs/setup.md#part-1-the-slack-app)
20
+ from [`slack-app-manifest.json`](https://github.com/duqaXxX/allaboard/blob/v0.4.0/slack-app-manifest.json),
21
+ the settings, and the service that starts allaboard at login:
22
+
23
+ ```sh
24
+ allaboard service install
25
+ ```
26
+
27
+ To bring another Mac or another Claude Code account into the same Slack, see
28
+ [the guide's Part 6](https://github.com/duqaXxX/allaboard/blob/v0.4.0/docs/setup.md#part-6-a-second-instance).
29
+
30
+ ## Update
31
+
32
+ When a new version is out, the bot's Home tab offers it with an Update button, and
33
+ `!allaboard update` offers it in any channel. Running turns finish first, then allaboard restarts
34
+ on the new version. From a terminal:
35
+
36
+ ```sh
37
+ allaboard update
38
+ ```
39
+
40
+ ## More
41
+
42
+ - Documentation and source: [github.com/duqaXxX/allaboard](https://github.com/duqaXxX/allaboard)
43
+ - Security model: [SECURITY.md](https://github.com/duqaXxX/allaboard/blob/v0.4.0/SECURITY.md)
44
+ - License: MIT
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The Claude back end: what the core asks of an agent, answered over the Agent SDK and over
3
+ * Claude Code's own records on disk. The only module a caller builds; the trust gate for a
4
+ * session's start is the core's (`folderTrusted` is its question to ask), not this class's.
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { shellQuote } from "../../text.js";
8
+ import { CAPABILITIES } from "./capabilities.js";
9
+ import { chromeEnabled } from "./chrome.js";
10
+ import { aliveSessions, byLastActivity, directorySessions, projectsDir } from "./listing.js";
11
+ import { ClaudeSession } from "./session.js";
12
+ import { trustedRepository, workspaceTrusted } from "./trust.js";
13
+ import { UsageProbe } from "./usage-probe.js";
14
+ export const NAME = "Claude Code";
15
+ /** The shell line that forks a session in a terminal; `environment` is what precedes `claude`. */
16
+ function terminalWith(environment) {
17
+ return `cd {directory} && ${environment}claude --resume {session} --fork-session`;
18
+ }
19
+ export const WORDS = Object.freeze({
20
+ author: "Claude",
21
+ command: "claude",
22
+ examples: "`!compact`, `!model opus`",
23
+ // `/clear` starts a new session under any of its names (aliases `/reset` and `/new`: commands
24
+ // reference, code.claude.com/docs/en/commands, read 2026-10-02, and the list SDK 0.2.163
25
+ // reports); `/login` and `/logout` act on the host's own login.
26
+ newSession: ["clear", "reset", "new"],
27
+ login: "login",
28
+ logout: "logout",
29
+ terminal: terminalWith(""),
30
+ });
31
+ /**
32
+ * `WORDS` for a back end whose sessions use the login in `configDir`: the line that continues a
33
+ * session in a terminal names that folder, since a terminal reads `~/.claude` without it. The
34
+ * folder is quoted for a shell as `{directory}` is; a brace in it is kept out of reach of the
35
+ * template's fields, `'"{"'` being the same word to a shell.
36
+ */
37
+ function wordsFor(configDir) {
38
+ if (configDir === undefined)
39
+ return WORDS;
40
+ const folder = shellQuote(configDir).replaceAll("{", `'"{"'`);
41
+ return Object.freeze({ ...WORDS, terminal: terminalWith(`CLAUDE_CONFIG_DIR=${folder} `) });
42
+ }
43
+ export class ClaudeBackend {
44
+ name = NAME;
45
+ words;
46
+ capabilities = CAPABILITIES;
47
+ #query;
48
+ #home;
49
+ #variables;
50
+ #configDir;
51
+ #projects;
52
+ #probe;
53
+ constructor(options = {}) {
54
+ this.#query = options.query;
55
+ this.#home = options.home ?? homedir();
56
+ this.#variables = options.variables ?? {};
57
+ // An empty variable is no folder, as Claude Code reads it.
58
+ this.#configDir = options.configDir ?? (process.env.CLAUDE_CONFIG_DIR || undefined);
59
+ this.#projects = projectsDir(this.#configDir, this.#home);
60
+ this.words = wordsFor(this.#configDir);
61
+ const bare = options.bareStart ?? ((folder, requests) => this.startBare(folder, requests));
62
+ this.#probe = new UsageProbe(this.#home, bare, { clock: options.clock });
63
+ }
64
+ /**
65
+ * Starts Claude Code and returns once it answered. Rejects with `ResumeRefused` when it
66
+ * refuses to resume `options.resume`, and with the failure itself when it cannot start.
67
+ * Whether the owner trusted the folder is the caller's to check first (`folderTrusted`).
68
+ */
69
+ async start(options, requests) {
70
+ // Read at every start, so a change in `/chrome` holds from a session's next start.
71
+ const chrome = await chromeEnabled(this.#home, this.#configDir);
72
+ const session = new ClaudeSession({ ...options, chrome, variables: this.#variables }, requests, this.#query);
73
+ await session.ready();
74
+ return session;
75
+ }
76
+ /**
77
+ * Starts a session for the daemon's own use, the usage probe's: the folder and no setting
78
+ * source, without the permission to bypass and without the Chrome flag, which only a thread's
79
+ * session is given. Fails as `start` does.
80
+ */
81
+ async startBare(folder, requests) {
82
+ const session = new ClaudeSession({
83
+ folder,
84
+ resume: null,
85
+ settingsSources: [],
86
+ model: null,
87
+ effort: null,
88
+ chrome: false,
89
+ bare: true,
90
+ }, requests, this.#query);
91
+ await session.ready();
92
+ return session;
93
+ }
94
+ /** The sessions of `folder` alone, newest first by their files' times. */
95
+ listSessions(folder) {
96
+ return directorySessions(folder);
97
+ }
98
+ /**
99
+ * `sessions` of `folder` by their last message, newest first, as the terminal's picker shows
100
+ * them. Dating reads files, so a caller that shows only some of a folder's sessions passes
101
+ * those alone.
102
+ */
103
+ datedSessions(folder, sessions) {
104
+ return byLastActivity(folder, sessions, { projects: this.#projects });
105
+ }
106
+ /** The session ids alive in `folder`, or null when it cannot tell (the pruning keeps them). */
107
+ aliveSessions(folder) {
108
+ return aliveSessions(folder, this.#projects);
109
+ }
110
+ folderTrusted(folder) {
111
+ return workspaceTrusted(folder, this.#home, this.#configDir);
112
+ }
113
+ trustedRepository(folder) {
114
+ return trustedRepository(folder, this.#home, this.#configDir);
115
+ }
116
+ /** The limits `/usage` reports now, read by a session of the back end's own. */
117
+ usageLimits() {
118
+ return this.#probe.read();
119
+ }
120
+ /** Stops the usage probe: its session is closed and no read starts another. */
121
+ close() {
122
+ return this.#probe.stop();
123
+ }
124
+ }
@@ -0,0 +1,30 @@
1
+ export const CAPABILITIES = Object.freeze({
2
+ // The SDK's `EffortLevel`.
3
+ effortLevels: Object.freeze(["low", "medium", "high", "xhigh", "max"]),
4
+ // The CLI's word for the model's default, set with `/effort auto`: not an `EffortLevel`.
5
+ effortDefault: "auto",
6
+ // The TypeScript SDK changes the effort of a live session (`applyFlagSettings`, no reconnect,
7
+ // SDK 0.3.296): 36 ms in the spike's measurement, 28 ms in the back end's own live check, both
8
+ // on 2026-10-10.
9
+ liveEffort: true,
10
+ permissionModes: Object.freeze({
11
+ default: "default",
12
+ auto: "auto",
13
+ bypass: "bypassPermissions",
14
+ }),
15
+ subagents: true,
16
+ backgroundTasks: true,
17
+ // A notification folded into a running turn is replayed as a `user` record whose origin names
18
+ // the run (`SDKMessageOrigin.runId`, recorded 2026-10-15, issue #317).
19
+ foldedNotifications: true,
20
+ compaction: true,
21
+ // `session_state_changed`, which a session emits when asked to (`SESSION_SIGNALS`).
22
+ declaredState: true,
23
+ questions: true,
24
+ usageLimits: true,
25
+ models: true,
26
+ commands: true,
27
+ promptDuringTurn: true,
28
+ sessionListing: true,
29
+ folderTrust: true,
30
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Claude Code's Chrome integration, for the sessions the daemon starts.
3
+ *
4
+ * Claude Code turns the integration on in an interactive session when the owner chose "Enabled by
5
+ * default" in `/chrome`: the key `claudeInChromeDefaultEnabled` of `~/.claude.json` (settings
6
+ * reference, read 2026-10-09). A session the Agent SDK starts is not interactive, and there the key
7
+ * connects nothing: the built-in `claude-in-chrome` server is listed only when Claude Code is
8
+ * started with `--chrome` (measured 2026-10-09, CLI 2.1.292). The daemon passes the flag when the
9
+ * key is on, so a session from Slack has the browser where the terminal has it.
10
+ *
11
+ * Nothing else is the daemon's. Which sites Claude may act on is the extension's own setting, and
12
+ * whether an action asks first is the session's permission mode, as in the terminal: a browser
13
+ * call that asks reaches Slack through `canUseTool` like any other tool (measured the same day,
14
+ * SDK 0.2.164).
15
+ */
16
+ import { readFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { getLogger } from "../../log.js";
19
+ import { errorName } from "../../values.js";
20
+ import { recordPath } from "./record.js";
21
+ export const logger = getLogger("allaboard.agent.claude.chrome");
22
+ // Strict UTF-8, and a BOM stays in the text, where `JSON.parse` refuses it.
23
+ const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
24
+ /**
25
+ * Whether the owner turned Chrome on by default in Claude Code; false when its record cannot be
26
+ * read. The record is the config folder's when the login has one (`recordPath`). Read at every
27
+ * call, so a change in `/chrome` holds from a session's next start.
28
+ */
29
+ export async function chromeEnabled(home = homedir(), configDir) {
30
+ let record;
31
+ try {
32
+ record = JSON.parse(STRICT_UTF8.decode(await readFile(recordPath(home, configDir))));
33
+ }
34
+ catch (error) {
35
+ logger.warning(`could not read Claude Code's Chrome setting: ${errorName(error)}`);
36
+ return false;
37
+ }
38
+ return (typeof record === "object" &&
39
+ record !== null &&
40
+ !Array.isArray(record) &&
41
+ record.claudeInChromeDefaultEnabled === true);
42
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The inputs of the three hooks the daemon registers, into session events.
3
+ *
4
+ * The Stop hook's input carries the effort level Claude Code runs at: the footer's only source
5
+ * for it, since no message reports it. Every hook input carries the `cwd` the session works in;
6
+ * PostToolUse reports it after each tool, so a turn stopped or failed before its Stop still
7
+ * moves the footer's branch. PostModelSwitch reports a model change before the command's result,
8
+ * while the context usage still names the model before it.
9
+ */
10
+ import { isRecord } from "../../values.js";
11
+ import { string, words } from "./wire.js";
12
+ function folderChanged(input) {
13
+ const folder = isRecord(input) ? words(input.cwd) : null;
14
+ return folder === null ? [] : [{ type: "folder_changed", folder }];
15
+ }
16
+ /**
17
+ * A Stop hook's input: the effort the session runs at, then its folder. The effort is always
18
+ * said: `effort` is in the CLI's Stop input since Claude Code 2.1.280 and absent when the model
19
+ * takes no effort parameter, which reads as null.
20
+ */
21
+ export function stopHookEvents(input) {
22
+ const effort = isRecord(input) && isRecord(input.effort) ? input.effort : {};
23
+ return [{ type: "effort_observed", level: string(effort.level) }, ...folderChanged(input)];
24
+ }
25
+ /** A PostToolUse hook's input: the folder the session works in after the tool. */
26
+ export function postToolUseHookEvents(input) {
27
+ return folderChanged(input);
28
+ }
29
+ /**
30
+ * A PostModelSwitch hook's input: the model the session runs after the switch. Every `source`
31
+ * counts (command, picker, sdk, auto, resume): each is a change of the model the footer names,
32
+ * and the one the daemon's own `setModel` makes is true as well.
33
+ */
34
+ export function postModelSwitchHookEvents(input) {
35
+ const model = isRecord(input) ? words(input.to_model) : null;
36
+ return model === null ? [] : [{ type: "model_changed", model }];
37
+ }
@@ -0,0 +1,61 @@
1
+ import { isRecord, records } from "../../values.js";
2
+ import { number, string, words } from "./wire.js";
3
+ function strings(value) {
4
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
5
+ }
6
+ function modelOf(entry) {
7
+ // The setup keys every option on `value`: an entry without one is left out here, so a CLI
8
+ // that lists one cannot stop every first prompt.
9
+ const value = words(entry.value);
10
+ if (value === null)
11
+ return null;
12
+ const supportsEffort = entry.supportsEffort === true;
13
+ return {
14
+ value,
15
+ resolvedModel: words(entry.resolvedModel),
16
+ displayName: words(entry.displayName) ?? value,
17
+ description: words(entry.description),
18
+ supportsEffort,
19
+ supportedEffortLevels: supportsEffort ? strings(entry.supportedEffortLevels) : [],
20
+ };
21
+ }
22
+ function commandOf(entry) {
23
+ const name = words(entry.name);
24
+ if (name === null)
25
+ return null;
26
+ return {
27
+ name,
28
+ description: string(entry.description) ?? "",
29
+ argumentHint: string(entry.argumentHint) ?? "",
30
+ aliases: strings(entry.aliases),
31
+ };
32
+ }
33
+ function known(entries) {
34
+ return entries.filter((entry) => entry !== null);
35
+ }
36
+ /** The answer to the initialize request as the models, commands and mode the core reads. */
37
+ export function agentInfo(info) {
38
+ const answer = isRecord(info) ? info : {};
39
+ return {
40
+ models: known(records(answer.models).map(modelOf)),
41
+ commands: known(records(answer.commands).map(commandOf)),
42
+ permissionMode: words(answer.current_permission_mode),
43
+ };
44
+ }
45
+ /** The context usage as the footer reads it: the model and the share of its window in use. */
46
+ export function contextUsage(usage) {
47
+ const answer = isRecord(usage) ? usage : {};
48
+ return { model: string(answer.model), percentage: number(answer.percentage) };
49
+ }
50
+ /** A session the SDK lists, as a row of `!resume`. */
51
+ export function listedSession(session) {
52
+ return {
53
+ id: session.sessionId,
54
+ title: session.summary,
55
+ customTitle: session.customTitle ?? null,
56
+ // Empty outside a repository's branch: the row then shows none.
57
+ branch: session.gitBranch || null,
58
+ size: session.fileSize ?? null,
59
+ lastModified: session.lastModified,
60
+ };
61
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Claude Code's sessions of one folder: the SDK's listing, their dates as the terminal's picker
3
+ * shows them, and the ids that are alive.
4
+ *
5
+ * What the public SDK does not give. `SDKSessionInfo.lastModified` is the transcript file's
6
+ * mtime, and `getSessionMessages` returns messages without a time (`SessionMessage` has no
7
+ * timestamp field, SDK 0.3.296 `sdk.d.ts`); no public function names a session's transcript path
8
+ * or a folder's project directory either, so the folder is derived here the way the package
9
+ * itself derives it (read in `sdk.mjs` 0.3.296, the functions that build `<config dir>/projects/<key>`): the real path, composed to NFC on
10
+ * macOS; every character that is not an ASCII letter or digit turned into `-`; past 200
11
+ * characters, the first 200, a `-` and the base-36 form of the absolute value of the 32-bit
12
+ * string hash `h = 31 * h + unit`. `test/agent/claude/listing.test.ts` writes a transcript where
13
+ * this derivation puts it and has the SDK's own `listSessions` find it, so a change in the
14
+ * package fails a test.
15
+ */
16
+ import { constants } from "node:fs";
17
+ import { open, readdir, realpath, stat } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
21
+ import { getLogger } from "../../log.js";
22
+ import { isRecord } from "../../values.js";
23
+ import { listedSession } from "./info.js";
24
+ import { string } from "./wire.js";
25
+ export const logger = getLogger("allaboard.agent.claude.listing");
26
+ /** The rows `!resume` lists: dating stops once the rest cannot enter them. */
27
+ export const RESUME_ROWS = 20;
28
+ /** The end of a transcript that is read for its last message. */
29
+ export const TAIL_BYTES = 256 * 1024;
30
+ // Past this many characters the CLI names a project folder with a hash (see the module doc).
31
+ export const LONG_PROJECT_KEY = 200;
32
+ /**
33
+ * Where Claude Code keeps every project's transcripts: "Claude Code stores session transcripts
34
+ * locally in plaintext under `~/.claude/projects/`" (https://code.claude.com/docs/en/data-usage,
35
+ * read 2026-09-28), under `CLAUDE_CONFIG_DIR` when that is set. `configDir` is that folder: the
36
+ * daemon's environment's when the caller gives none, so the probe, which builds no module, reads
37
+ * what a terminal would.
38
+ */
39
+ export function projectsDir(configDir = process.env.CLAUDE_CONFIG_DIR, home = homedir()) {
40
+ const config = configDir ? configDir : join(home, ".claude");
41
+ return join(process.platform === "darwin" ? config.normalize("NFC") : config, "projects");
42
+ }
43
+ /** The real path of `directory`; the path itself when it cannot be resolved. */
44
+ async function canonical(directory) {
45
+ let path = directory;
46
+ try {
47
+ path = await realpath(directory);
48
+ }
49
+ catch {
50
+ // A folder that does not exist keeps the name it was given.
51
+ }
52
+ return process.platform === "darwin" ? path.normalize("NFC") : path;
53
+ }
54
+ function stringHash(text) {
55
+ let hash = 0;
56
+ for (let index = 0; index < text.length; index += 1) {
57
+ hash = ((hash << 5) - hash + text.charCodeAt(index)) | 0;
58
+ }
59
+ return hash;
60
+ }
61
+ /** The name of `directory`'s folder under the projects directory. */
62
+ export async function projectKey(directory) {
63
+ const path = await canonical(directory);
64
+ const sanitized = path.replace(/[^a-zA-Z0-9]/g, "-");
65
+ if (sanitized.length <= LONG_PROJECT_KEY)
66
+ return sanitized;
67
+ return `${sanitized.slice(0, LONG_PROJECT_KEY)}-${Math.abs(stringHash(path)).toString(36)}`;
68
+ }
69
+ /**
70
+ * Whether `path` is a directory; null when its `stat` fails for a reason other than the path not
71
+ * being there (`ENOENT`, `ENOTDIR`), such as `EACCES`: the folder may well be there.
72
+ */
73
+ async function directoryState(path) {
74
+ try {
75
+ return (await stat(path)).isDirectory();
76
+ }
77
+ catch (error) {
78
+ const code = isRecord(error) ? error.code : undefined;
79
+ return code === "ENOENT" || code === "ENOTDIR" ? false : null;
80
+ }
81
+ }
82
+ async function isDirectory(path) {
83
+ return (await directoryState(path)) === true;
84
+ }
85
+ /**
86
+ * The folder that holds `directory`'s transcripts, or null when there is none. The hash of a long
87
+ * name is the CLI's to compute and may differ from ours, so the first folder with the same first
88
+ * 200 characters stands in for it.
89
+ */
90
+ export async function projectFolder(directory, projects) {
91
+ const key = await projectKey(directory);
92
+ const exact = join(projects, key);
93
+ if (await isDirectory(exact))
94
+ return exact;
95
+ if (key.length <= LONG_PROJECT_KEY)
96
+ return null;
97
+ const prefix = key.slice(0, LONG_PROJECT_KEY + 1);
98
+ try {
99
+ for (const entry of await readdir(projects, { withFileTypes: true })) {
100
+ if (entry.isDirectory() && entry.name.startsWith(prefix))
101
+ return join(projects, entry.name);
102
+ }
103
+ }
104
+ catch {
105
+ // No projects directory: no folder.
106
+ }
107
+ return null;
108
+ }
109
+ /** The sessions of `directory` alone, newest first. */
110
+ export async function directorySessions(directory) {
111
+ // The terminal's picker also starts from the current worktree (sessions reference, read
112
+ // 2026-09-25). The programmatic sessions stay in: the daemon's own are among them.
113
+ const found = await sdkListSessions({ dir: directory, includeWorktrees: false });
114
+ return found.map(listedSession).sort((a, b) => b.lastModified - a.lastModified);
115
+ }
116
+ /**
117
+ * The time of the last user or assistant entry in a transcript, read from its end; null when
118
+ * there is none or the file cannot be read.
119
+ *
120
+ * `listSessions` dates a session by its file's mtime, and Claude Code appends bookkeeping
121
+ * entries with no timestamp to old transcripts (artifact ledgers, seen 2026-09-25 with the
122
+ * bundled CLI 2.1.280): a session untouched for days then reads as minutes old. The terminal's
123
+ * picker shows the last activity, so this reads it. The transcript format is not documented.
124
+ */
125
+ export async function lastMessageMs(path) {
126
+ let tail;
127
+ try {
128
+ // As `trust.ts` opens its small files: a link is not followed and a FIFO is not waited on.
129
+ // The flags do not exist on Windows.
130
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
131
+ try {
132
+ const found = await file.stat();
133
+ if (!found.isFile())
134
+ return null;
135
+ const { size } = found;
136
+ const start = Math.max(0, size - TAIL_BYTES);
137
+ const buffer = Buffer.alloc(size - start);
138
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
139
+ tail = buffer.subarray(0, bytesRead).toString("utf8");
140
+ }
141
+ finally {
142
+ await file.close();
143
+ }
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ for (const line of tail.split(/\r\n|\r|\n/).reverse()) {
149
+ let entry;
150
+ try {
151
+ entry = JSON.parse(line);
152
+ }
153
+ catch {
154
+ continue; // the first line of the tail may be cut
155
+ }
156
+ if (isRecord(entry) && (entry.type === "user" || entry.type === "assistant")) {
157
+ const stamp = string(entry.timestamp);
158
+ if (stamp === null)
159
+ continue;
160
+ const time = Date.parse(stamp);
161
+ return Number.isNaN(time) ? null : time;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ /**
167
+ * `sessions` ordered by their last message, as the terminal's picker shows them: every session
168
+ * that can be among the newest `rows` is dated, the rest keep their file's mtime.
169
+ */
170
+ export async function byLastActivity(directory, sessions, options = {}) {
171
+ const { projects = projectsDir(), rows = RESUME_ROWS, readStamp = lastMessageMs } = options;
172
+ const folder = await projectFolder(directory, projects);
173
+ if (folder === null) {
174
+ // Also what a change in the package's naming would look like: say so.
175
+ logger.warning("found no transcript folder: session dates fall back to file times");
176
+ return [...sessions];
177
+ }
178
+ const byMtime = [...sessions].sort((a, b) => b.lastModified - a.lastModified);
179
+ const dated = [];
180
+ const newest = []; // the `rows` newest stamps dated so far, largest first
181
+ for (const [index, session] of byMtime.entries()) {
182
+ // A file's mtime bounds its last message from above: once `rows` dated sessions are at or
183
+ // above this mtime, no file left can enter the list, so none is read.
184
+ const oldest = newest[rows - 1];
185
+ if (oldest !== undefined && oldest >= session.lastModified) {
186
+ return [...newestFirst(dated), ...byMtime.slice(index)];
187
+ }
188
+ const stamp = (await readStamp(join(folder, `${session.id}.jsonl`))) || session.lastModified;
189
+ dated.push({ ...session, lastModified: stamp });
190
+ newest.push(stamp);
191
+ newest.sort((a, b) => b - a);
192
+ newest.length = Math.min(newest.length, rows);
193
+ }
194
+ return newestFirst(dated);
195
+ }
196
+ function newestFirst(sessions) {
197
+ return [...sessions].sort((a, b) => b.lastModified - a.lastModified);
198
+ }
199
+ /**
200
+ * The session ids alive in `directory`, for the state's pruning: those the listing shows,
201
+ * unioned with every transcript file actually there. The listing skips sidechain and
202
+ * metadata-only sessions (the SDK's own rules): a session real enough to be stored in a thread
203
+ * must never be pruned just because it has not built up a title yet. Null is "cannot tell", and
204
+ * the pruning then keeps the folder's threads: past `LONG_PROJECT_KEY` characters the CLI names
205
+ * the folder with a hash we may not reproduce, so a missing folder there proves nothing; and a
206
+ * folder that is there and cannot be read, or whose presence cannot be checked, cannot tell either
207
+ * (a listing reads an unreadable folder as an empty one, and every session would look gone).
208
+ */
209
+ export async function aliveSessions(directory, projects = projectsDir()) {
210
+ const alive = new Set((await directorySessions(directory)).map((session) => session.id));
211
+ const key = await projectKey(directory);
212
+ const folder = join(projects, key);
213
+ const there = await directoryState(folder);
214
+ if (there === null)
215
+ return null;
216
+ if (!there)
217
+ return key.length > LONG_PROJECT_KEY ? null : alive;
218
+ let names;
219
+ try {
220
+ names = await readdir(folder);
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ for (const name of names) {
226
+ if (name.endsWith(".jsonl"))
227
+ alive.add(name.slice(0, -".jsonl".length));
228
+ }
229
+ return alive;
230
+ }