@phnx-labs/agents-cli 1.16.0 → 1.17.1

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 (64) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/commands/browser.js +248 -9
  3. package/dist/commands/cloud.js +8 -0
  4. package/dist/commands/exec.js +70 -1
  5. package/dist/commands/import.d.ts +24 -0
  6. package/dist/commands/import.js +203 -0
  7. package/dist/commands/plugins.js +179 -5
  8. package/dist/commands/prune.js +6 -0
  9. package/dist/commands/secrets.js +117 -19
  10. package/dist/commands/view.js +21 -8
  11. package/dist/commands/workflows.d.ts +10 -0
  12. package/dist/commands/workflows.js +457 -0
  13. package/dist/index.js +34 -16
  14. package/dist/lib/browser/cdp.js +7 -4
  15. package/dist/lib/browser/chrome.d.ts +10 -0
  16. package/dist/lib/browser/chrome.js +37 -2
  17. package/dist/lib/browser/drivers/local.js +13 -2
  18. package/dist/lib/browser/input.d.ts +1 -0
  19. package/dist/lib/browser/input.js +3 -0
  20. package/dist/lib/browser/ipc.js +14 -0
  21. package/dist/lib/browser/profiles.d.ts +5 -0
  22. package/dist/lib/browser/profiles.js +45 -0
  23. package/dist/lib/browser/service.d.ts +10 -0
  24. package/dist/lib/browser/service.js +29 -1
  25. package/dist/lib/browser/types.d.ts +11 -1
  26. package/dist/lib/cloud/rush.d.ts +28 -1
  27. package/dist/lib/cloud/rush.js +68 -13
  28. package/dist/lib/commands.d.ts +0 -15
  29. package/dist/lib/commands.js +5 -5
  30. package/dist/lib/hooks.js +24 -11
  31. package/dist/lib/import.d.ts +91 -0
  32. package/dist/lib/import.js +179 -0
  33. package/dist/lib/migrate.js +59 -1
  34. package/dist/lib/permissions.d.ts +0 -58
  35. package/dist/lib/permissions.js +10 -10
  36. package/dist/lib/plugins.d.ts +75 -34
  37. package/dist/lib/plugins.js +640 -133
  38. package/dist/lib/resource-patterns.d.ts +41 -0
  39. package/dist/lib/resource-patterns.js +82 -0
  40. package/dist/lib/resources/index.d.ts +17 -0
  41. package/dist/lib/resources/index.js +7 -0
  42. package/dist/lib/resources/types.d.ts +1 -1
  43. package/dist/lib/resources/workflows.d.ts +24 -0
  44. package/dist/lib/resources/workflows.js +110 -0
  45. package/dist/lib/resources.d.ts +6 -1
  46. package/dist/lib/resources.js +12 -2
  47. package/dist/lib/session/db.d.ts +18 -0
  48. package/dist/lib/session/db.js +106 -7
  49. package/dist/lib/session/discover.d.ts +6 -0
  50. package/dist/lib/session/discover.js +28 -17
  51. package/dist/lib/shims.d.ts +3 -51
  52. package/dist/lib/shims.js +18 -10
  53. package/dist/lib/sqlite.js +10 -4
  54. package/dist/lib/state.d.ts +15 -2
  55. package/dist/lib/state.js +29 -8
  56. package/dist/lib/types.d.ts +43 -14
  57. package/dist/lib/versions.d.ts +3 -0
  58. package/dist/lib/versions.js +139 -27
  59. package/dist/lib/workflows.d.ts +79 -0
  60. package/dist/lib/workflows.js +233 -0
  61. package/package.json +1 -5
  62. package/scripts/postinstall.js +59 -58
  63. package/dist/commands/fork.d.ts +0 -10
  64. package/dist/commands/fork.js +0 -146
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Resource selection patterns for agents.yaml versions: entries.
3
+ *
4
+ * Pattern syntax: [!]source:name
5
+ * "system:*" — all resources from ~/.agents-system/
6
+ * "user:*" — all resources from ~/.agents/
7
+ * "rush:*" — all resources from ~/.agents-rush/ (extra repo alias)
8
+ * "project:*" — all resources from .agents/ in the project root
9
+ * "user:foo" — specifically the resource named "foo" from ~/.agents/
10
+ * "!user:temp" — exclude "temp" from the user repo
11
+ *
12
+ * Evaluation rule: union all inclusions, then subtract all exclusions.
13
+ */
14
+ export interface ParsedPattern {
15
+ negate: boolean;
16
+ source: string;
17
+ name: string;
18
+ }
19
+ export declare function parsePattern(p: string): ParsedPattern;
20
+ /** Returns true if the string is a legacy plain name with no source: prefix. */
21
+ export declare function isLegacyName(p: string): boolean;
22
+ /**
23
+ * Expand a list of patterns against an available name→source map.
24
+ * Returns the union of matching names with exclusions subtracted.
25
+ *
26
+ * Supports comma-grouped names to avoid repeating the source prefix:
27
+ * "system:brain-scan,mq" → includes brain-scan and mq from system
28
+ * "!user:temp,draft" → excludes temp and draft from user
29
+ *
30
+ * Note: in YAML flow sequences ([...]) a comma inside a pattern requires
31
+ * quoting ("system:brain-scan,mq"). Block-style items and yaml.stringify
32
+ * output handle this automatically.
33
+ */
34
+ export declare function expandPatterns(patterns: string[], available: Map<string, string>): string[];
35
+ /**
36
+ * Build the default pattern list for a resource type.
37
+ * Order: system → user → alias1 → alias2 → ... → project (base-to-override).
38
+ * @param extraAliases Alias names of enabled extra repos, in insertion order.
39
+ * @param includeProject Whether to append "project:*". False for hooks (security).
40
+ */
41
+ export declare function defaultPatterns(extraAliases?: string[], includeProject?: boolean): string[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Resource selection patterns for agents.yaml versions: entries.
3
+ *
4
+ * Pattern syntax: [!]source:name
5
+ * "system:*" — all resources from ~/.agents-system/
6
+ * "user:*" — all resources from ~/.agents/
7
+ * "rush:*" — all resources from ~/.agents-rush/ (extra repo alias)
8
+ * "project:*" — all resources from .agents/ in the project root
9
+ * "user:foo" — specifically the resource named "foo" from ~/.agents/
10
+ * "!user:temp" — exclude "temp" from the user repo
11
+ *
12
+ * Evaluation rule: union all inclusions, then subtract all exclusions.
13
+ */
14
+ export function parsePattern(p) {
15
+ const negate = p.startsWith('!');
16
+ const raw = negate ? p.slice(1) : p;
17
+ const colon = raw.indexOf(':');
18
+ if (colon === -1) {
19
+ throw new Error(`Invalid resource pattern "${p}": expected "source:name" format`);
20
+ }
21
+ return { negate, source: raw.slice(0, colon), name: raw.slice(colon + 1) };
22
+ }
23
+ /** Returns true if the string is a legacy plain name with no source: prefix. */
24
+ export function isLegacyName(p) {
25
+ return !p.startsWith('!') && !p.includes(':');
26
+ }
27
+ /**
28
+ * Expand a list of patterns against an available name→source map.
29
+ * Returns the union of matching names with exclusions subtracted.
30
+ *
31
+ * Supports comma-grouped names to avoid repeating the source prefix:
32
+ * "system:brain-scan,mq" → includes brain-scan and mq from system
33
+ * "!user:temp,draft" → excludes temp and draft from user
34
+ *
35
+ * Note: in YAML flow sequences ([...]) a comma inside a pattern requires
36
+ * quoting ("system:brain-scan,mq"). Block-style items and yaml.stringify
37
+ * output handle this automatically.
38
+ */
39
+ export function expandPatterns(patterns, available) {
40
+ const included = new Set();
41
+ const excluded = new Set();
42
+ for (const p of patterns) {
43
+ try {
44
+ const { negate, source, name } = parsePattern(p);
45
+ const target = negate ? excluded : included;
46
+ // Comma-grouped names: "system:brain-scan,mq" → ['brain-scan', 'mq']
47
+ const names = name === '*' ? ['*'] : name.split(',').map(n => n.trim()).filter(Boolean);
48
+ for (const n of names) {
49
+ if (n === '*') {
50
+ for (const [rn, rs] of available) {
51
+ if (rs === source)
52
+ target.add(rn);
53
+ }
54
+ }
55
+ else {
56
+ if (available.has(n))
57
+ target.add(n);
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // Skip malformed patterns
63
+ }
64
+ }
65
+ return [...included].filter(n => !excluded.has(n));
66
+ }
67
+ /**
68
+ * Build the default pattern list for a resource type.
69
+ * Order: system → user → alias1 → alias2 → ... → project (base-to-override).
70
+ * @param extraAliases Alias names of enabled extra repos, in insertion order.
71
+ * @param includeProject Whether to append "project:*". False for hooks (security).
72
+ */
73
+ export function defaultPatterns(extraAliases = [], includeProject = true) {
74
+ const patterns = ['system:*', 'user:*'];
75
+ for (const alias of extraAliases) {
76
+ patterns.push(`${alias}:*`);
77
+ }
78
+ if (includeProject) {
79
+ patterns.push('project:*');
80
+ }
81
+ return patterns;
82
+ }
@@ -13,6 +13,7 @@ export { RulesHandler, type RuleItem } from './rules.js';
13
13
  export { McpHandler, getMcpConfigPath, type McpItem } from './mcp.js';
14
14
  export { PermissionsHandler, type PermissionItem } from './permissions.js';
15
15
  export { SubagentsHandler, subagentsHandler, type SubagentItem } from './subagents.js';
16
+ export { WorkflowsHandler, type WorkflowItem } from './workflows.js';
16
17
  import type { ResourceKind, ResourceHandler } from './types.js';
17
18
  /** All resource handlers keyed by kind. */
18
19
  export declare const handlers: {
@@ -29,6 +30,22 @@ export declare const handlers: {
29
30
  readonly permissions: ResourceHandler<import("../types.js").PermissionSet>;
30
31
  readonly subagent: import("./subagents.js").SubagentsHandler;
31
32
  readonly subagents: import("./subagents.js").SubagentsHandler;
33
+ readonly workflow: {
34
+ readonly kind: "workflow";
35
+ listAll(_agent: import("./types.js").AgentId, cwd?: string): import("./types.js").ResolvedItem<import("./workflows.js").WorkflowItem>[];
36
+ resolve(_agent: import("./types.js").AgentId, name: string, cwd?: string): import("./types.js").ResolvedItem<import("./workflows.js").WorkflowItem> | null;
37
+ sync(_agent: import("./types.js").AgentId, _versionHome: string, _cwd?: string): void;
38
+ format(_agent: import("./types.js").AgentId): "md";
39
+ targetDir(_agent: import("./types.js").AgentId): string;
40
+ };
41
+ readonly workflows: {
42
+ readonly kind: "workflow";
43
+ listAll(_agent: import("./types.js").AgentId, cwd?: string): import("./types.js").ResolvedItem<import("./workflows.js").WorkflowItem>[];
44
+ resolve(_agent: import("./types.js").AgentId, name: string, cwd?: string): import("./types.js").ResolvedItem<import("./workflows.js").WorkflowItem> | null;
45
+ sync(_agent: import("./types.js").AgentId, _versionHome: string, _cwd?: string): void;
46
+ format(_agent: import("./types.js").AgentId): "md";
47
+ targetDir(_agent: import("./types.js").AgentId): string;
48
+ };
32
49
  };
33
50
  /** Get a handler by resource kind. */
34
51
  export declare function getHandler(kind: ResourceKind): ResourceHandler<unknown> | null;
@@ -13,6 +13,7 @@ export { RulesHandler } from './rules.js';
13
13
  export { McpHandler, getMcpConfigPath } from './mcp.js';
14
14
  export { PermissionsHandler } from './permissions.js';
15
15
  export { SubagentsHandler, subagentsHandler } from './subagents.js';
16
+ export { WorkflowsHandler } from './workflows.js';
16
17
  import { commandsHandler } from './commands.js';
17
18
  import { HooksHandler } from './hooks.js';
18
19
  import { SkillsHandler } from './skills.js';
@@ -20,6 +21,7 @@ import { RulesHandler } from './rules.js';
20
21
  import { McpHandler } from './mcp.js';
21
22
  import { PermissionsHandler } from './permissions.js';
22
23
  import { subagentsHandler } from './subagents.js';
24
+ import { WorkflowsHandler } from './workflows.js';
23
25
  /** All resource handlers keyed by kind. */
24
26
  export const handlers = {
25
27
  command: commandsHandler,
@@ -35,6 +37,8 @@ export const handlers = {
35
37
  permissions: PermissionsHandler,
36
38
  subagent: subagentsHandler,
37
39
  subagents: subagentsHandler,
40
+ workflow: WorkflowsHandler,
41
+ workflows: WorkflowsHandler,
38
42
  };
39
43
  /** Get a handler by resource kind. */
40
44
  export function getHandler(kind) {
@@ -53,6 +57,8 @@ export function getHandler(kind) {
53
57
  return PermissionsHandler;
54
58
  case 'subagent':
55
59
  return subagentsHandler;
60
+ case 'workflow':
61
+ return WorkflowsHandler;
56
62
  default:
57
63
  return null;
58
64
  }
@@ -66,4 +72,5 @@ export const RESOURCE_KINDS = [
66
72
  'mcp',
67
73
  'permission',
68
74
  'subagent',
75
+ 'workflow',
69
76
  ];
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
- export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent';
10
+ export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow';
11
11
  /** A resolved resource with its origin layer. */
12
12
  export interface ResolvedItem<T> {
13
13
  name: string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Workflows resource handler.
3
+ *
4
+ * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
+ * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
+ * Resolution order: project > user > system.
7
+ */
8
+ import type { AgentId, ResolvedItem, ResourceHandler } from './types.js';
9
+ export interface WorkflowItem {
10
+ name: string;
11
+ description: string;
12
+ model?: string;
13
+ subagentCount: number;
14
+ }
15
+ declare class WorkflowsHandlerImpl implements ResourceHandler<WorkflowItem> {
16
+ readonly kind: "workflow";
17
+ listAll(_agent: AgentId, cwd?: string): ResolvedItem<WorkflowItem>[];
18
+ resolve(_agent: AgentId, name: string, cwd?: string): ResolvedItem<WorkflowItem> | null;
19
+ sync(_agent: AgentId, _versionHome: string, _cwd?: string): void;
20
+ format(_agent: AgentId): 'md';
21
+ targetDir(_agent: AgentId): string;
22
+ }
23
+ export declare const WorkflowsHandler: WorkflowsHandlerImpl;
24
+ export {};
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Workflows resource handler.
3
+ *
4
+ * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
+ * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
+ * Resolution order: project > user > system.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { getProjectAgentsDir, getUserWorkflowsDir, getSystemWorkflowsDir, getEnabledExtraRepos, } from '../state.js';
11
+ import { parseWorkflowFrontmatter, countWorkflowSubagents } from '../workflows.js';
12
+ function getLayerDirs(cwd) {
13
+ const projectDir = getProjectAgentsDir(cwd);
14
+ const extraRepos = getEnabledExtraRepos();
15
+ return {
16
+ system: getSystemWorkflowsDir(),
17
+ user: getUserWorkflowsDir(),
18
+ project: projectDir ? path.join(projectDir, 'workflows') : null,
19
+ extra: extraRepos.map(e => path.join(e.dir, 'workflows')),
20
+ };
21
+ }
22
+ function listWorkflowsInDir(dir) {
23
+ if (!fs.existsSync(dir))
24
+ return [];
25
+ try {
26
+ return fs.readdirSync(dir, { withFileTypes: true })
27
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') &&
28
+ fs.existsSync(path.join(dir, e.name, 'WORKFLOW.md')))
29
+ .map(e => ({ name: e.name, path: path.join(dir, e.name) }));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ class WorkflowsHandlerImpl {
36
+ kind = 'workflow';
37
+ listAll(_agent, cwd) {
38
+ const dirs = getLayerDirs(cwd);
39
+ const seen = new Set();
40
+ const results = [];
41
+ const layerDirs = [];
42
+ if (dirs.project)
43
+ layerDirs.push({ dir: dirs.project, layer: 'project' });
44
+ layerDirs.push({ dir: dirs.user, layer: 'user' });
45
+ layerDirs.push({ dir: dirs.system, layer: 'system' });
46
+ for (const extraDir of dirs.extra)
47
+ layerDirs.push({ dir: extraDir, layer: 'system' });
48
+ for (const { dir, layer } of layerDirs) {
49
+ for (const { name, path: workflowPath } of listWorkflowsInDir(dir)) {
50
+ if (seen.has(name))
51
+ continue;
52
+ const fm = parseWorkflowFrontmatter(workflowPath);
53
+ if (!fm)
54
+ continue;
55
+ seen.add(name);
56
+ results.push({
57
+ name,
58
+ item: {
59
+ name: fm.name || name,
60
+ description: fm.description,
61
+ model: fm.model,
62
+ subagentCount: countWorkflowSubagents(workflowPath),
63
+ },
64
+ layer,
65
+ path: workflowPath,
66
+ });
67
+ }
68
+ }
69
+ return results.sort((a, b) => a.name.localeCompare(b.name));
70
+ }
71
+ resolve(_agent, name, cwd) {
72
+ const dirs = getLayerDirs(cwd);
73
+ const searchDirs = [];
74
+ if (dirs.project)
75
+ searchDirs.push({ dir: dirs.project, layer: 'project' });
76
+ searchDirs.push({ dir: dirs.user, layer: 'user' });
77
+ searchDirs.push({ dir: dirs.system, layer: 'system' });
78
+ for (const extraDir of dirs.extra)
79
+ searchDirs.push({ dir: extraDir, layer: 'system' });
80
+ for (const { dir, layer } of searchDirs) {
81
+ const workflowPath = path.join(dir, name);
82
+ const fm = parseWorkflowFrontmatter(workflowPath);
83
+ if (fm) {
84
+ return {
85
+ name,
86
+ item: {
87
+ name: fm.name || name,
88
+ description: fm.description,
89
+ model: fm.model,
90
+ subagentCount: countWorkflowSubagents(workflowPath),
91
+ },
92
+ layer,
93
+ path: workflowPath,
94
+ };
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ sync(_agent, _versionHome, _cwd) {
100
+ // Version-home copies are written by syncResourcesToVersion in versions.ts.
101
+ // exec.ts resolves workflows at run time from source dirs directly.
102
+ }
103
+ format(_agent) {
104
+ return 'md';
105
+ }
106
+ targetDir(_agent) {
107
+ return 'workflows';
108
+ }
109
+ }
110
+ export const WorkflowsHandler = new WorkflowsHandlerImpl();
@@ -11,7 +11,11 @@ export interface ResolvedResource {
11
11
  name: string;
12
12
  /** Absolute path to the resource file or directory. */
13
13
  path: string;
14
- source: 'project' | 'user' | 'system';
14
+ /**
15
+ * Source layer: 'project' | 'user' | 'system' for built-in layers,
16
+ * or the alias name (e.g. 'rush') for extra repos registered in agents.yaml.
17
+ */
18
+ source: string;
15
19
  }
16
20
  /**
17
21
  * Resolve a single resource by kind + name using project > user > system precedence.
@@ -52,6 +56,7 @@ export interface AgentResources {
52
56
  mcp: McpResourceEntry[];
53
57
  memory: ResourceEntry[];
54
58
  hooks: ResourceEntry[];
59
+ workflows: ResourceEntry[];
55
60
  }
56
61
  /** Options for resource discovery. */
57
62
  export interface GetAgentResourcesOptions {
@@ -11,6 +11,8 @@ import { listInstalledHooksWithScope } from './hooks.js';
11
11
  import { listInstalledInstructionsWithScope } from './rules/rules.js';
12
12
  import { getEffectiveHome } from './versions.js';
13
13
  import { listMcpServerConfigs } from './mcp.js';
14
+ import { WorkflowsHandler } from './resources/workflows.js';
15
+ import { WORKFLOW_CAPABLE_AGENTS } from './workflows.js';
14
16
  import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, } from './state.js';
15
17
  /**
16
18
  * Resolve a single resource by kind + name using project > user > system precedence.
@@ -26,7 +28,7 @@ export function resolveResource(kind, name, cwd) {
26
28
  ...(projectDir ? [[path.join(projectDir, kind), 'project']] : []),
27
29
  [path.join(getUserAgentsDir(), kind), 'user'],
28
30
  [path.join(getSystemAgentsDir(), kind), 'system'],
29
- ...extraRepos.map((e) => [path.join(e.dir, kind), 'system']),
31
+ ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias]),
30
32
  ];
31
33
  for (const [dir, source] of candidates) {
32
34
  if (!fs.existsSync(dir))
@@ -60,7 +62,7 @@ export function listResources(kind, cwd) {
60
62
  ...(projectDir ? [[path.join(projectDir, kind), 'project']] : []),
61
63
  [path.join(getUserAgentsDir(), kind), 'user'],
62
64
  [path.join(getSystemAgentsDir(), kind), 'system'],
63
- ...extraRepos.map((e) => [path.join(e.dir, kind), 'system']),
65
+ ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias]),
64
66
  ];
65
67
  for (const [dir, source] of roots) {
66
68
  if (!fs.existsSync(dir))
@@ -159,6 +161,13 @@ export function getAgentResources(agentId, options = {}) {
159
161
  hooks.push({ name: hook.name, path: hook.path, scope: hook.scope });
160
162
  }
161
163
  }
164
+ // Workflows (claude only)
165
+ const workflows = [];
166
+ if (WORKFLOW_CAPABLE_AGENTS.includes(agentId)) {
167
+ for (const w of WorkflowsHandler.listAll(agentId, cwd)) {
168
+ workflows.push({ name: w.name, path: w.path, scope: w.layer === 'project' ? 'project' : 'user' });
169
+ }
170
+ }
162
171
  return {
163
172
  agentId,
164
173
  commands,
@@ -167,6 +176,7 @@ export function getAgentResources(agentId, options = {}) {
167
176
  mcp,
168
177
  memory,
169
178
  hooks,
179
+ workflows,
170
180
  };
171
181
  }
172
182
  /**
@@ -55,6 +55,24 @@ export interface QueryOptions {
55
55
  export declare function getDB(): Database.Database;
56
56
  /** Close the cached database connection. */
57
57
  export declare function closeDB(): void;
58
+ /**
59
+ * Try to claim the right to run the incremental scan. Returns true if this
60
+ * process should proceed with scanning, false if another live process is
61
+ * already scanning (caller should skip the scan and serve from the DB).
62
+ *
63
+ * Uses the `meta` table so it survives crashes — dead PIDs are detected via
64
+ * process.kill(pid, 0), stale entries via TTL. No external lock files needed.
65
+ *
66
+ * Wrapped in db.transaction() (BEGIN IMMEDIATE) so the read-then-write is
67
+ * atomic and busy_timeout retries correctly — bare auto-commit DML in WAL
68
+ * mode can return SQLITE_BUSY_SNAPSHOT which bypasses the busy handler.
69
+ */
70
+ export declare function tryClaimScan(pid: number): boolean;
71
+ /**
72
+ * Release the scan claim written by tryClaimScan. Only deletes the entry
73
+ * if it still belongs to this process (guards against TTL takeovers).
74
+ */
75
+ export declare function releaseScan(pid: number): void;
58
76
  /** Return the absolute path to the sessions database file. */
59
77
  export declare function getDBPath(): string;
60
78
  /**
@@ -151,16 +151,17 @@ export function getDB() {
151
151
  db.pragma('journal_mode = WAL');
152
152
  db.pragma('synchronous = NORMAL');
153
153
  db.pragma('temp_store = MEMORY');
154
- // Wait up to 10s instead of failing immediately on SQLITE_BUSY. Multiple
155
- // agents (CLIs, indexers, hooks) all open this DB concurrently; without a
156
- // busy timeout, parallel writers throw "database is locked" the moment one
157
- // holds the write lock. 10s is well above any realistic transaction here.
158
- db.pragma('busy_timeout = 10000');
154
+ // Wait up to 30s instead of failing immediately on SQLITE_BUSY. Multiple
155
+ // agents (CLIs, skills, hooks) open this DB concurrently. The first scan of
156
+ // a new version home can take longer than 10s; concurrent callers need enough
157
+ // headroom to wait. The ledger-recheck in upsertSessionsBatch makes
158
+ // subsequent writers near-instant, so 30s is a rarely-reached safety net.
159
+ db.pragma('busy_timeout = 30000');
159
160
  db.exec(SCHEMA);
160
161
  const current = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
161
162
  const currentVersion = current ? parseInt(current.value, 10) : 0;
162
163
  if (!current) {
163
- db.prepare(`INSERT INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
164
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
164
165
  }
165
166
  else if (currentVersion < SCHEMA_VERSION) {
166
167
  migrateSchema(db, currentVersion);
@@ -181,7 +182,7 @@ export function getDB() {
181
182
  }
182
183
  catch { /* ignore */ }
183
184
  }
184
- db.prepare(`INSERT INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
185
+ db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('legacy_indexes_removed', '1')`).run();
185
186
  }
186
187
  dbInstance = db;
187
188
  return db;
@@ -193,6 +194,75 @@ export function closeDB() {
193
194
  dbInstance = null;
194
195
  }
195
196
  }
197
+ // ---------------------------------------------------------------------------
198
+ // Scan coordinator — prevents concurrent full scans across processes
199
+ // ---------------------------------------------------------------------------
200
+ /** How long a scan claim is trusted before it's considered stale (ms). */
201
+ const SCAN_CLAIM_TTL_MS = 120_000; // 2 minutes
202
+ function isProcessAlive(pid) {
203
+ if (!pid || isNaN(pid))
204
+ return false;
205
+ try {
206
+ process.kill(pid, 0);
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ /**
214
+ * Try to claim the right to run the incremental scan. Returns true if this
215
+ * process should proceed with scanning, false if another live process is
216
+ * already scanning (caller should skip the scan and serve from the DB).
217
+ *
218
+ * Uses the `meta` table so it survives crashes — dead PIDs are detected via
219
+ * process.kill(pid, 0), stale entries via TTL. No external lock files needed.
220
+ *
221
+ * Wrapped in db.transaction() (BEGIN IMMEDIATE) so the read-then-write is
222
+ * atomic and busy_timeout retries correctly — bare auto-commit DML in WAL
223
+ * mode can return SQLITE_BUSY_SNAPSHOT which bypasses the busy handler.
224
+ */
225
+ export function tryClaimScan(pid) {
226
+ const db = getDB();
227
+ const txn = db.transaction(() => {
228
+ const existing = db
229
+ .prepare(`SELECT value FROM meta WHERE key = 'scan_in_progress'`)
230
+ .get();
231
+ if (existing) {
232
+ const parts = existing.value.split(':');
233
+ const existingPid = parseInt(parts[0], 10);
234
+ const existingTs = parseInt(parts[1], 10);
235
+ const ageMs = Date.now() - existingTs;
236
+ if (isProcessAlive(existingPid) && ageMs < SCAN_CLAIM_TTL_MS) {
237
+ return false; // another live process is scanning — skip
238
+ }
239
+ // Dead PID or expired TTL — take over below
240
+ }
241
+ db.prepare(`INSERT OR REPLACE INTO meta (key, value) VALUES ('scan_in_progress', ?)`)
242
+ .run(`${pid}:${Date.now()}`);
243
+ return true;
244
+ });
245
+ return txn();
246
+ }
247
+ /**
248
+ * Release the scan claim written by tryClaimScan. Only deletes the entry
249
+ * if it still belongs to this process (guards against TTL takeovers).
250
+ */
251
+ export function releaseScan(pid) {
252
+ const db = getDB();
253
+ const txn = db.transaction(() => {
254
+ const existing = db
255
+ .prepare(`SELECT value FROM meta WHERE key = 'scan_in_progress'`)
256
+ .get();
257
+ if (!existing)
258
+ return;
259
+ const claimPid = parseInt(existing.value.split(':')[0], 10);
260
+ if (claimPid === pid) {
261
+ db.prepare(`DELETE FROM meta WHERE key = 'scan_in_progress'`).run();
262
+ }
263
+ });
264
+ txn();
265
+ }
196
266
  /** Return the absolute path to the sessions database file. */
197
267
  export function getDBPath() {
198
268
  return DB_PATH;
@@ -367,8 +437,37 @@ export function upsertSessionsBatch(entries) {
367
437
  file_size = excluded.file_size,
368
438
  scanned_at = excluded.scanned_at
369
439
  `);
440
+ // Build a lookup from canonical file path → entry, used inside the write
441
+ // transaction to re-check the ledger AFTER acquiring the lock. When a
442
+ // concurrent process already committed the same files between our
443
+ // filterChangedFiles call and now, the ledger will have matching (mtime, size)
444
+ // rows — we skip those entries, making the second writer's transaction a
445
+ // near-instant no-op rather than redundant work.
446
+ const byPath = new Map(entries
447
+ .filter(e => e.scan && e.meta.filePath)
448
+ .map(e => [canonicalLedgerKey(e.meta.filePath), e]));
370
449
  const txn = db.transaction((items) => {
450
+ // Re-read the ledger now that we hold the write lock. Any file committed
451
+ // by a concurrent process since our pre-scan is visible here.
452
+ const CHUNK = 500; // stay under SQLite's 999-variable limit
453
+ const alreadyIndexed = new Set();
454
+ const paths = [...byPath.keys()];
455
+ for (let i = 0; i < paths.length; i += CHUNK) {
456
+ const chunk = paths.slice(i, i + CHUNK);
457
+ const phs = chunk.map(() => '?').join(',');
458
+ const rows = db
459
+ .prepare(`SELECT file_path, file_mtime_ms, file_size FROM scan_ledger WHERE file_path IN (${phs})`)
460
+ .all(...chunk);
461
+ for (const row of rows) {
462
+ const entry = byPath.get(row.file_path);
463
+ if (entry && row.file_mtime_ms === entry.scan.fileMtimeMs && row.file_size === entry.scan.fileSize) {
464
+ alreadyIndexed.add(entry.meta.id);
465
+ }
466
+ }
467
+ }
371
468
  for (const { meta, content, scan } of items) {
469
+ if (alreadyIndexed.has(meta.id))
470
+ continue;
372
471
  upsert.run({
373
472
  id: meta.id,
374
473
  short_id: meta.shortId,
@@ -37,6 +37,12 @@ export interface ScanProgress {
37
37
  /**
38
38
  * Discover sessions. Scans only files whose (mtime, size) have changed since
39
39
  * the last run; everything else is served from the SQLite cache.
40
+ *
41
+ * Only one process runs the incremental scan at a time. When many agents boot
42
+ * simultaneously (e.g. after a restart), the first to claim the scan slot does
43
+ * the work; the rest skip parsing entirely and serve from the DB. The claim is
44
+ * stored in the `meta` table — crash-safe via dead-PID detection and a 2-min
45
+ * TTL, no external lock files needed.
40
46
  */
41
47
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
42
48
  /**