Repository navigation
fix(native-chat): /clear starts nothing; the new chat's first message starts its agent #23935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6dad39a
97bf3bb
427dc8d
2bd44f5
f79eb9b
50d2e33
bd72b13
fa9b76a
5435973
8fd4558
12dde37
c753e8a
82f9bfd
f3e52d2
29a340b
c7a652f
34d0bb0
46b6472
283819d
40dabe8
31444bf
494d8c7
0e74529
6c16f35
d2f646b
25fa38a
aa56b50
d23cca4
4993696
a2ad8f2
48def96
da65d57
0184a3d
12577d8
e751c7e
a59eed9
ccaf412
9123568
0efca93
36067f3
a47a902
69540f8
918091d
74d6506
9c671f1
eaf1436
5b93c44
40f3d35
80ccb33
97a5321
4c67270
7805005
dfe35eb
3d3c52b
0a3f255
4c45865
1887bef
a862d71
8caf8d7
0871b27
6cde2a7
a96f475
9530e8d
2f39e1b
b35ee58
d4d9d71
4ad06ca
1aaf776
9d9a8ae
daaf0cf
20318fe
42307c8
48cdb21
9d9dd98
e7458c1
bab2cef
75f5607
50a3316
cf8e2fe
15b2efa
09e359f
bbec1b6
7a5e202
33c42e3
3e561ee
c0ea360
60826fc
b8526e4
b680294
e6dadd1
a96d3fc
8cbdd44
4e391e1
70736fd
a6c7153
7b6eafb
a73c33a
f6d9c8a
e10362b
159b971
20978d6
ff274a7
eed0954
ed8dd9f
b7ebbdb
742bda7
93ee58a
b36d188
7214f65
26fc397
d242e5e
ca3e68f
194fc33
86d7365
e17c80e
fb53224
ccb0610
e6922a5
1c48bf1
d7ce43a
6374f35
0f89353
724b961
9fa4452
4d36cd3
8e37dc9
6545e9c
8eef3fe
7f8eda9
c39151d
14dc4db
897768d
be2d4e1
309777a
0fc424a
9613178
0224459
2276cef
9275869
8fea3f0
8c966c7
1474464
6a316b9
09e94b8
158ca0a
43e5c9f
301762e
8f66a25
61cd62e
dcc85e6
da2ba05
2a21363
0217612
ad04c3b
05b8b93
e463c6a
6bf458f
320a4ff
065d98f
dc4b176
a72cec4
199e2d6
446d65f
d5716f3
f92abe2
3ea60f2
4bf6080
ea4dbfb
740ed1f
f74a642
fb34994
69ff6df
7002b1b
cb5ce82
cec2d77
f4963ad
fcad779
5479e31
57735e6
5b9379d
fd6b1f4
e9e2b3a
f7639bf
3e789fc
9da7cd0
ea99997
f1bf5b4
d89674a
1b2f025
4cd918b
97e7925
aceb39f
e6aaca2
7913e6f
537d795
ebbb88c
436d177
d6d4b29
066b4ee
90b68d3
acdd559
7b9fb3f
caa5be6
1e6c37d
9c847cb
bda9ff1
077454c
321a58a
7b2f2d9
898daae
54b147e
8a44a4e
84fc7ff
8995771
1e087ec
b67389a
4974af9
dc4ca58
9d3f676
3bffed3
990bff2
8b0f189
c9b008b
66e9658
7da6f8d
a6534ad
f26e803
d57545b
386f986
9199f97
18624fb
5b6cd57
85a12cb
4581395
b0ccff4
e338bca
7251f4e
d6c17bd
ee31bbe
d22b8cb
d4e7d53
59c1445
9366ef7
36708c2
7508af3
86acc5e
e3619c2
58e63a1
10d019f
62d506a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { join } from 'node:path' | ||
| import type { AgentSessionRecord } from '../../shared/agent-session-record' | ||
| import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' | ||
| import { structuredSlashCommands } from '../../shared/structured-agent-session-composer' | ||
| import { discoverSkills } from '../skills/discovery' | ||
| import { scanClaudeCommandFolders } from './claude-command-folder-scan' | ||
| import { supportsClaudeStructuredLocation } from './claude-structured-location-support' | ||
|
|
||
| /** How long one scan answers for a workspace and account before the next read scans again. */ | ||
| export const CLAUDE_AT_REST_COMMANDS_TTL_MS = 10_000 | ||
| /** A scan still unanswered this long no longer holds the next one off, so a hung folder can't | ||
| * freeze the menu. At most two run at once, so a folder that stays hung holds no more. */ | ||
| const SCAN_OVERDUE_MS = 30_000 | ||
| const MAX_SCANS_IN_FLIGHT = 2 | ||
|
|
||
| export type ClaudeAtRestCommandsDeps = { | ||
| resolveWorkspacePath: (workspaceId: string) => Promise<string> | ||
| now?: () => number | ||
| discover?: typeof discoverSkills | ||
| } | ||
|
|
||
| type Entry = { | ||
| commands?: AgentSessionSlashCommand[] | ||
| /** When the kept answer, or the last failure, landed. */ | ||
| scannedAt: number | ||
| lastStartedAt: number | ||
| inFlight: number | ||
| /** Numbers each scan, so only an answer newer than the one kept replaces it. */ | ||
| started: number | ||
| kept: number | ||
| } | ||
|
|
||
| /** | ||
| * The `/` surface of a Claude chat whose Claude is not running, read from the folders Claude reads | ||
| * its own from on the host that runs it: the account's and the workspace's command folders and | ||
| * skills, beside the commands the chat's menu always offers. Nothing until the first scan lands; | ||
| * `onChange` fires when a scan finds something different. | ||
| */ | ||
| export class ClaudeAtRestCommandCatalog { | ||
| private readonly entries = new Map<string, Entry>() | ||
| private readonly listeners = new Set<() => void>() | ||
|
|
||
| constructor(private readonly deps: ClaudeAtRestCommandsDeps) {} | ||
|
|
||
| read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined => { | ||
| // Another host's folders are only readable there, so this host never answers for them. | ||
| if (record.provider !== 'claude' || !supportsClaudeStructuredLocation(record.location)) { | ||
| return undefined | ||
| } | ||
| const key = JSON.stringify([record.location.workspaceId, record.accountHome.path]) | ||
| let entry = this.entries.get(key) | ||
| if (!entry) { | ||
| entry = { | ||
| scannedAt: Number.NEGATIVE_INFINITY, | ||
| lastStartedAt: Number.NEGATIVE_INFINITY, | ||
| inFlight: 0, | ||
| started: 0, | ||
| kept: 0 | ||
| } | ||
| this.entries.set(key, entry) | ||
| } | ||
| const now = this.now() | ||
| const due = | ||
| entry.inFlight === 0 | ||
| ? now - entry.scannedAt >= CLAUDE_AT_REST_COMMANDS_TTL_MS | ||
| : entry.inFlight < MAX_SCANS_IN_FLIGHT && now - entry.lastStartedAt >= SCAN_OVERDUE_MS | ||
| if (due) { | ||
| this.scanInto(entry, record) | ||
| } | ||
| return entry.commands | ||
| } | ||
|
|
||
| onChange = (listener: () => void): (() => void) => { | ||
| this.listeners.add(listener) | ||
| return () => this.listeners.delete(listener) | ||
| } | ||
|
|
||
| private scanInto(entry: Entry, record: AgentSessionRecord): void { | ||
| const scan = ++entry.started | ||
| entry.lastStartedAt = this.now() | ||
| entry.inFlight += 1 | ||
| const newest = () => { | ||
| if (scan < entry.kept) { | ||
| return false | ||
| } | ||
| entry.kept = scan | ||
| entry.scannedAt = this.now() | ||
| return true | ||
| } | ||
| void this.scan(record) | ||
| .then( | ||
| (commands) => { | ||
| if (!newest() || JSON.stringify(commands) === JSON.stringify(entry.commands)) { | ||
| return | ||
| } | ||
| entry.commands = commands | ||
| for (const listener of this.listeners) { | ||
| listener() | ||
| } | ||
| }, | ||
| (error: unknown) => { | ||
| // Waits out the window before trying again, but leaves room for an older scan's answer. | ||
| entry.scannedAt = this.now() | ||
| console.warn('[claude] reading the at-rest `/` commands failed:', error) | ||
| } | ||
| ) | ||
| .finally(() => { | ||
| entry.inFlight -= 1 | ||
| }) | ||
| } | ||
|
|
||
| private async scan(record: AgentSessionRecord): Promise<AgentSessionSlashCommand[]> { | ||
| const cwd = await this.deps.resolveWorkspacePath(record.location.workspaceId) | ||
| const account = record.accountHome.path | ||
| const [custom, discovered] = await Promise.all([ | ||
| scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), | ||
| (this.deps.discover ?? discoverSkills)({ | ||
| repos: [], | ||
| cwd, | ||
| providerRootOverrides: { claude: join(account, 'skills') } | ||
| }) | ||
| ]) | ||
| // What the menu offers a Claude chat at rest without a list, so reading one loses none of it. | ||
| const builtIn = structuredSlashCommands(['clear', 'compact'], 'claude').map( | ||
| (command): AgentSessionSlashCommand => ({ | ||
| name: command.name, | ||
| kind: 'command', | ||
| ...(command.description ? { description: command.description } : {}) | ||
| }) | ||
| ) | ||
| const names = new Set(builtIn.map((command) => command.name)) | ||
| const skills = discovered.skills | ||
| .filter((skill) => skill.providers.includes('claude')) | ||
| .map((skill): AgentSessionSlashCommand => ({ | ||
| name: skill.name, | ||
| kind: 'skill', | ||
| ...(skill.description ? { description: skill.description } : {}) | ||
| })) | ||
| return [...builtIn, ...custom.filter((command) => !names.has(command.name)), ...skills] | ||
| } | ||
|
|
||
| private now(): number { | ||
| return this.deps.now?.() ?? Date.now() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import type { Dirent } from 'node:fs' | ||
| import { opendir, readFile } from 'node:fs/promises' | ||
| import { join, relative, sep } from 'node:path' | ||
| import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' | ||
| import { summarizeSkillMarkdown } from '../../shared/skill-metadata' | ||
|
|
||
| const MARKDOWN_EXTENSION = '.md' | ||
| const MAX_SCAN_DEPTH = 24 | ||
| const MAX_SCAN_ENTRIES = 1_000 | ||
| const MAX_COMMAND_FILE_BYTES = 256 * 1024 | ||
|
|
||
| type ScanBudget = { remainingEntries: number } | ||
|
|
||
| /** A folder that cannot be read holds no commands we can offer, so it is skipped, not an error. */ | ||
| async function readEntries(path: string, budget: ScanBudget): Promise<Dirent[]> { | ||
| try { | ||
| const entries: Dirent[] = [] | ||
| for await (const entry of await opendir(path)) { | ||
| if (budget.remainingEntries === 0) { | ||
| break | ||
| } | ||
| budget.remainingEntries -= 1 | ||
| entries.push(entry) | ||
| } | ||
| return entries.sort((left, right) => left.name.localeCompare(right.name)) | ||
| } catch { | ||
| return [] | ||
| } | ||
| } | ||
|
|
||
| async function walkMarkdown( | ||
| path: string, | ||
| depth: number, | ||
| budget: ScanBudget, | ||
| found: string[] | ||
| ): Promise<void> { | ||
| if (depth > MAX_SCAN_DEPTH || budget.remainingEntries === 0) { | ||
| return | ||
| } | ||
| for (const entry of await readEntries(path, budget)) { | ||
| // Links are not followed: a command folder is the user's own files, not a way out of it. | ||
| if (entry.isSymbolicLink()) { | ||
| continue | ||
| } | ||
| const entryPath = join(path, entry.name) | ||
| if (entry.isDirectory()) { | ||
| await walkMarkdown(entryPath, depth + 1, budget, found) | ||
| } else if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXTENSION)) { | ||
| found.push(entryPath) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function describe(filePath: string): Promise<string | undefined> { | ||
| try { | ||
| const markdown = await readFile(filePath, 'utf8') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an at-rest Claude chat scans a workspace containing a large How this was verified: An at-rest menu read reaches the workspace command scanner, which reads each Markdown file in full before truncating its contents. |
||
| return ( | ||
| summarizeSkillMarkdown(markdown.slice(0, MAX_COMMAND_FILE_BYTES)).description ?? undefined | ||
| ) | ||
|
Comment on lines
+54
to
+59
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win Limit the read before parsing the command file.
Proposed fix-import { opendir, readFile } from 'node:fs/promises'
+import { open, opendir } from 'node:fs/promises'
@@
async function describe(filePath: string): Promise<string | undefined> {
+ const handle = await open(filePath, 'r').catch(() => null)
+ if (!handle) {
+ return undefined
+ }
try {
- const markdown = await readFile(filePath, 'utf8')
- return (
- summarizeSkillMarkdown(markdown.slice(0, MAX_COMMAND_FILE_BYTES)).description ?? undefined
- )
+ const buffer = Buffer.alloc(MAX_COMMAND_FILE_BYTES)
+ const { bytesRead } = await handle.read(buffer, 0, MAX_COMMAND_FILE_BYTES, 0)
+ return summarizeSkillMarkdown(buffer.subarray(0, bytesRead).toString('utf8')).description ?? undefined
} catch {
return undefined
+ } finally {
+ await handle.close()
}
} |
||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Claude's custom commands in `roots`: every `*.md` below each folder, named by its path from | ||
| * the folder with `:` between segments (`frontend/test.md` is `/frontend:test`). The first root | ||
| * to name a command wins, so list the closest folder first. | ||
| */ | ||
| export async function scanClaudeCommandFolders( | ||
| roots: readonly string[] | ||
| ): Promise<AgentSessionSlashCommand[]> { | ||
| const budget = { remainingEntries: MAX_SCAN_ENTRIES } | ||
| const commands = new Map<string, AgentSessionSlashCommand>() | ||
| for (const root of roots) { | ||
| const files: string[] = [] | ||
| await walkMarkdown(root, 0, budget, files) | ||
| for (const file of files) { | ||
| const name = relative(root, file).slice(0, -MARKDOWN_EXTENSION.length).split(sep).join(':') | ||
| if (commands.has(name)) { | ||
| continue | ||
| } | ||
| const description = await describe(file) | ||
| commands.set(name, { name, kind: 'command', ...(description ? { description } : {}) }) | ||
| } | ||
| } | ||
| return [...commands.values()] | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If two scans hang, this key is frozen forever.
inFlightis decremented only in.finally, which runs whenscan()settles, andscan()has no timeout — so once both slots are occupiedinFlight < MAX_SCANS_IN_FLIGHTis permanently false,duenever becomes true again, and the/surface stays at its last value until relaunch. This contradicts the comment above ("a hung folder can't freeze the menu") and the PR body's "a scan that has not answered in 30 seconds is given up on".Technical details