nebula-ai-core 0.1.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 (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
package/src/chain.ts ADDED
@@ -0,0 +1,75 @@
1
+ import {
2
+ http,
3
+ type Chain,
4
+ type Hex,
5
+ type PublicClient,
6
+ type WalletClient,
7
+ createPublicClient,
8
+ createWalletClient,
9
+ defineChain,
10
+ } from 'viem'
11
+ import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'
12
+ import { NETWORK_CHAIN_ID, NETWORK_RPC, type NebulaNetwork } from './config'
13
+
14
+ /**
15
+ * Static fallback floor when `eth_gasPrice` is unreachable. 4 gwei matches the
16
+ * current Mantle mainnet floor (verified Apr 27 2026). Real callers should prefer
17
+ * `getGasPriceWithFloor` so the value tracks network conditions; this constant
18
+ * is the safety net.
19
+ *
20
+ * History: was 2.5 gwei; bumped to 4 gwei when txs began rejecting with
21
+ * "gas required exceeds allowance" (Geth's misleading wording for min-fee
22
+ * rejection, not OOG).
23
+ */
24
+ export const MIN_GAS_PRICE = 4_000_000_000n
25
+
26
+ /**
27
+ * Read the network's current `eth_gasPrice` and return `max(networkPrice, MIN_GAS_PRICE)`.
28
+ * Falls back to MIN_GAS_PRICE on RPC failure. Always returns a value safe to
29
+ * pass as `maxFeePerGas` / `maxPriorityFeePerGas` for an EIP-1559 tx; the
30
+ * floor protects against a momentarily-low quote, and using the live value
31
+ * means we don't underpay when the network's floor moves up.
32
+ */
33
+ export async function getGasPriceWithFloor(client: PublicClient): Promise<bigint> {
34
+ try {
35
+ const price = await client.getGasPrice()
36
+ return price > MIN_GAS_PRICE ? price : MIN_GAS_PRICE
37
+ } catch {
38
+ return MIN_GAS_PRICE
39
+ }
40
+ }
41
+
42
+ /** Empirical gas budget for `Mantle Storage Flow.submit()`. Used by preflight balance checks. */
43
+ export const STORAGE_SUBMIT_GAS = 250_000n
44
+
45
+ export function mantleChain(network: NebulaNetwork): Chain {
46
+ const isMainnet = network === 'mantle-mainnet'
47
+ return defineChain({
48
+ id: NETWORK_CHAIN_ID[network],
49
+ name: isMainnet ? 'Mantle' : 'Mantle Sepolia Testnet',
50
+ nativeCurrency: { name: 'Mantle', symbol: 'MNT', decimals: 18 },
51
+ rpcUrls: { default: { http: [NETWORK_RPC[network]] } },
52
+ blockExplorers: {
53
+ default: {
54
+ name: isMainnet ? 'MantleScan' : 'Mantle Sepolia Explorer',
55
+ url: isMainnet ? 'https://mantlescan.xyz' : 'https://sepolia.mantlescan.xyz',
56
+ },
57
+ },
58
+ })
59
+ }
60
+
61
+ export interface ViemClients {
62
+ chain: Chain
63
+ account: PrivateKeyAccount
64
+ publicClient: PublicClient
65
+ walletClient: WalletClient
66
+ }
67
+
68
+ export function makeViemClients(opts: { network: NebulaNetwork; privkeyHex: Hex }): ViemClients {
69
+ const chain = mantleChain(opts.network)
70
+ const account = privateKeyToAccount(opts.privkeyHex)
71
+ const transport = http(NETWORK_RPC[opts.network])
72
+ const publicClient = createPublicClient({ transport, chain })
73
+ const walletClient = createWalletClient({ transport, account, chain })
74
+ return { chain, account, publicClient, walletClient }
75
+ }
@@ -0,0 +1,152 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { readFile, readdir, stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { ClaudeAgent, ClaudeCommand, ClaudeExtrasDiscoveryResult } from './types'
6
+
7
+ export interface ClaudeExtrasOptions {
8
+ importsClaudeCode?: boolean
9
+ /** Override for ~/.claude/plugins/cache/. */
10
+ claudePluginsCacheRoot?: string
11
+ }
12
+
13
+ export async function discoverClaudeExtras(
14
+ opts: ClaudeExtrasOptions = {},
15
+ ): Promise<ClaudeExtrasDiscoveryResult> {
16
+ const importsClaudeCode = opts.importsClaudeCode ?? true
17
+ if (!importsClaudeCode) return { commands: [], agents: [] }
18
+ const cacheRoot = opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
19
+ const commands: ClaudeCommand[] = []
20
+ const agents: ClaudeAgent[] = []
21
+
22
+ let marketplaces: Dirent[]
23
+ try {
24
+ const s = await stat(cacheRoot)
25
+ if (!s.isDirectory()) return { commands, agents }
26
+ marketplaces = (await readdir(cacheRoot, { withFileTypes: true })) as Dirent[]
27
+ } catch {
28
+ return { commands, agents }
29
+ }
30
+
31
+ for (const market of marketplaces) {
32
+ if (!market.isDirectory()) continue
33
+ const marketDir = join(cacheRoot, market.name)
34
+ let plugins: Dirent[]
35
+ try {
36
+ plugins = (await readdir(marketDir, { withFileTypes: true })) as Dirent[]
37
+ } catch {
38
+ continue
39
+ }
40
+ for (const plugin of plugins) {
41
+ if (!plugin.isDirectory()) continue
42
+ const pluginDir = join(marketDir, plugin.name)
43
+ let versions: Dirent[]
44
+ try {
45
+ versions = (await readdir(pluginDir, { withFileTypes: true })) as Dirent[]
46
+ } catch {
47
+ continue
48
+ }
49
+ const versionDirs = versions
50
+ .filter(v => v.isDirectory())
51
+ .map(v => v.name)
52
+ .sort()
53
+ const latest = versionDirs[versionDirs.length - 1]
54
+ if (!latest) continue
55
+ const versionDir = join(pluginDir, latest)
56
+ const source = { marketplace: market.name, plugin: plugin.name, version: latest }
57
+ await collectFromDir(join(versionDir, 'commands'), source, 'command', commands, agents)
58
+ await collectFromDir(join(versionDir, 'agents'), source, 'agent', commands, agents)
59
+ }
60
+ }
61
+ return { commands, agents }
62
+ }
63
+
64
+ async function collectFromDir(
65
+ dir: string,
66
+ source: { marketplace: string; plugin: string; version: string },
67
+ kind: 'command' | 'agent',
68
+ commands: ClaudeCommand[],
69
+ agents: ClaudeAgent[],
70
+ ): Promise<void> {
71
+ let entries: Dirent[]
72
+ try {
73
+ const s = await stat(dir)
74
+ if (!s.isDirectory()) return
75
+ entries = (await readdir(dir, { withFileTypes: true })) as Dirent[]
76
+ } catch {
77
+ return
78
+ }
79
+ for (const entry of entries) {
80
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
81
+ const filePath = join(dir, entry.name)
82
+ let raw: string
83
+ try {
84
+ raw = await readFile(filePath, 'utf8')
85
+ } catch {
86
+ continue
87
+ }
88
+ const parsed = parseFile(raw)
89
+ if (!parsed) continue
90
+ const id = `${source.plugin}:${parsed.name ?? entry.name.replace(/\.md$/, '')}`
91
+ const name = parsed.name ?? entry.name.replace(/\.md$/, '')
92
+ if (kind === 'command') {
93
+ commands.push({
94
+ id,
95
+ name,
96
+ description: parsed.description ?? '',
97
+ argumentHint: parsed.argumentHint,
98
+ path: filePath,
99
+ body: parsed.body,
100
+ source,
101
+ })
102
+ } else {
103
+ agents.push({
104
+ id,
105
+ name,
106
+ description: parsed.description ?? '',
107
+ model: parsed.model,
108
+ path: filePath,
109
+ body: parsed.body,
110
+ source,
111
+ })
112
+ }
113
+ }
114
+ }
115
+
116
+ interface ParsedFile {
117
+ name?: string
118
+ description?: string
119
+ argumentHint?: string
120
+ model?: string
121
+ body: string
122
+ }
123
+
124
+ function parseFile(raw: string): ParsedFile | null {
125
+ if (!raw.startsWith('---')) {
126
+ return { body: raw }
127
+ }
128
+ const end = raw.indexOf('\n---', 4)
129
+ if (end === -1) return { body: raw }
130
+ const block = raw.slice(4, end)
131
+ const body = raw.slice(end + 4).replace(/^\n/, '')
132
+ const out: ParsedFile = { body }
133
+ for (const line of block.split('\n')) {
134
+ const m = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/)
135
+ if (!m?.[1]) continue
136
+ const key = m[1]
137
+ const value = unquote(m[2] ?? '')
138
+ if (key === 'name') out.name = value
139
+ else if (key === 'description') out.description = value
140
+ else if (key === 'argument-hint' || key === 'argumentHint') out.argumentHint = value
141
+ else if (key === 'model') out.model = value
142
+ }
143
+ return out
144
+ }
145
+
146
+ function unquote(s: string): string {
147
+ const t = s.trim()
148
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
149
+ return t.slice(1, -1)
150
+ }
151
+ return t
152
+ }
@@ -0,0 +1,6 @@
1
+ export { discoverClaudeExtras, type ClaudeExtrasOptions } from './discovery'
2
+ export type {
3
+ ClaudeCommand,
4
+ ClaudeAgent,
5
+ ClaudeExtrasDiscoveryResult,
6
+ } from './types'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Phase 9.2 Bundle 8 surfaces: Claude Code commands + agents discovered from
3
+ * the local plugin cache. Both are markdown files with YAML frontmatter; the
4
+ * body is the prompt that nebula inlines when the command/agent fires.
5
+ */
6
+
7
+ export interface ClaudeCommand {
8
+ /** `<plugin>:<name>` (nebula drops the marketplace prefix; the cmd surface is flat). */
9
+ id: string
10
+ /** Bare command name (e.g. `setup`, `mode`, `commit`). */
11
+ name: string
12
+ description: string
13
+ /** Optional argument-hint shown after the slash command in help. */
14
+ argumentHint?: string
15
+ /** Absolute path to the markdown file. */
16
+ path: string
17
+ /** Body without frontmatter; this is the prompt template nebula inlines. */
18
+ body: string
19
+ /** Source plugin coordinates (marketplace, plugin, version). */
20
+ source: { marketplace: string; plugin: string; version: string }
21
+ }
22
+
23
+ export interface ClaudeAgent {
24
+ /** `<plugin>:<name>` */
25
+ id: string
26
+ name: string
27
+ description: string
28
+ /** Optional model hint from frontmatter (e.g. `sonnet`). nebula ignores it (uses configured brain). */
29
+ model?: string
30
+ path: string
31
+ body: string
32
+ source: { marketplace: string; plugin: string; version: string }
33
+ }
34
+
35
+ export interface ClaudeExtrasDiscoveryResult {
36
+ commands: ClaudeCommand[]
37
+ agents: ClaudeAgent[]
38
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ COMMAND_REGISTRY,
3
+ applyPerms,
4
+ applyYolo,
5
+ commandsForSurface,
6
+ findCommand,
7
+ parseSlash,
8
+ suggestForPrefix,
9
+ type ApplyResult,
10
+ type CommandScope,
11
+ type CommandSurface,
12
+ type ParsedSlash,
13
+ type PermissionApi,
14
+ type PermissionToggleMode,
15
+ type SlashCommand,
16
+ } from './registry'
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Shared slash-command registry. The TUI autocomplete popup, TG
3
+ * `setMyCommands` registration, and the various bypass dispatchers all read
4
+ * from this single source so command lists never drift between surfaces.
5
+ */
6
+
7
+ export type CommandSurface = 'tui' | 'tg'
8
+
9
+ /**
10
+ * - `local` : runs in the CLI process (TUI handler, local TG via chat-telegram)
11
+ * - `gateway`: runs in the gateway process (sandbox harness or local-mode gateway daemon)
12
+ * - `both` : valid in either surface; routing decided by where the command arrives
13
+ */
14
+ export type CommandScope = 'local' | 'gateway' | 'both'
15
+
16
+ export interface SlashCommand {
17
+ /** Command name without leading slash, e.g. "yolo". Lowercased. */
18
+ name: string
19
+ /** One-line human description used in TUI menu + TG client menu. */
20
+ description: string
21
+ /** Surfaces the command should appear in for discovery. */
22
+ surfaces: CommandSurface[]
23
+ /** Where the command actually executes. */
24
+ scope: CommandScope
25
+ /** True when the command short-circuits before brain.infer (control commands). */
26
+ bypassesBrain: boolean
27
+ /** Optional argument hint shown next to the name in menus, e.g. "off|prompt|strict". */
28
+ argHint?: string
29
+ }
30
+
31
+ export const COMMAND_REGISTRY: SlashCommand[] = [
32
+ {
33
+ name: 'yolo',
34
+ description: 'Toggle approval prompts on/off',
35
+ surfaces: ['tui', 'tg'],
36
+ scope: 'both',
37
+ bypassesBrain: true,
38
+ },
39
+ {
40
+ name: 'perms',
41
+ description: 'Set permission mode',
42
+ surfaces: ['tui', 'tg'],
43
+ scope: 'both',
44
+ bypassesBrain: true,
45
+ argHint: 'off|prompt|strict',
46
+ },
47
+ {
48
+ name: 'reset',
49
+ description: 'Clear conversation history for this channel',
50
+ surfaces: ['tui', 'tg'],
51
+ scope: 'both',
52
+ bypassesBrain: true,
53
+ },
54
+ {
55
+ name: 'sync',
56
+ description: 'Force memory sync to Mantle Storage',
57
+ surfaces: ['tui'],
58
+ scope: 'local',
59
+ bypassesBrain: true,
60
+ },
61
+ {
62
+ name: 'model',
63
+ description: 'Show brain model switch hint',
64
+ surfaces: ['tui'],
65
+ scope: 'local',
66
+ bypassesBrain: true,
67
+ },
68
+ {
69
+ name: 'jobs',
70
+ description: 'List active marketplace jobs',
71
+ surfaces: ['tui'],
72
+ scope: 'local',
73
+ bypassesBrain: true,
74
+ },
75
+ {
76
+ name: 'help',
77
+ description: 'Show all commands',
78
+ surfaces: ['tui'],
79
+ scope: 'local',
80
+ bypassesBrain: true,
81
+ },
82
+ {
83
+ name: 'exit',
84
+ description: 'Quit nebula',
85
+ surfaces: ['tui'],
86
+ scope: 'local',
87
+ bypassesBrain: true,
88
+ },
89
+ {
90
+ name: 'quit',
91
+ description: 'Quit nebula',
92
+ surfaces: ['tui'],
93
+ scope: 'local',
94
+ bypassesBrain: true,
95
+ },
96
+ {
97
+ name: 'stop',
98
+ description: 'Cancel current turn',
99
+ surfaces: ['tg'],
100
+ scope: 'gateway',
101
+ bypassesBrain: true,
102
+ },
103
+ {
104
+ name: 'new',
105
+ description: 'Start a fresh session',
106
+ surfaces: ['tg'],
107
+ scope: 'gateway',
108
+ bypassesBrain: true,
109
+ },
110
+ {
111
+ name: 'status',
112
+ description: 'Show agent status',
113
+ surfaces: ['tg'],
114
+ scope: 'gateway',
115
+ bypassesBrain: true,
116
+ },
117
+ {
118
+ name: 'approve',
119
+ description: 'Approve the pending request',
120
+ surfaces: ['tg'],
121
+ scope: 'gateway',
122
+ bypassesBrain: true,
123
+ },
124
+ {
125
+ name: 'deny',
126
+ description: 'Deny the pending request',
127
+ surfaces: ['tg'],
128
+ scope: 'gateway',
129
+ bypassesBrain: true,
130
+ },
131
+ {
132
+ name: 'background',
133
+ description: 'Move the current turn to background',
134
+ surfaces: ['tg'],
135
+ scope: 'gateway',
136
+ bypassesBrain: true,
137
+ },
138
+ {
139
+ name: 'restart',
140
+ description: 'Restart the active session',
141
+ surfaces: ['tg'],
142
+ scope: 'gateway',
143
+ bypassesBrain: true,
144
+ },
145
+ ]
146
+
147
+ export function commandsForSurface(surface: CommandSurface): SlashCommand[] {
148
+ return COMMAND_REGISTRY.filter(c => c.surfaces.includes(surface))
149
+ }
150
+
151
+ export function findCommand(name: string): SlashCommand | undefined {
152
+ const needle = name.replace(/^\/+/, '').toLowerCase()
153
+ return COMMAND_REGISTRY.find(c => c.name === needle)
154
+ }
155
+
156
+ export interface ParsedSlash {
157
+ /** Raw command name lowercased, no leading slash. */
158
+ name: string
159
+ /** Whitespace-split argv after the command. */
160
+ args: string[]
161
+ /** Resolved registry entry when name matches a known command, otherwise undefined. */
162
+ command?: SlashCommand
163
+ }
164
+
165
+ /**
166
+ * Parse a leading-slash message into name + args. Returns null when the input
167
+ * doesn't start with a slash. Unknown command names still return a parsed
168
+ * shape (with `command` undefined) so callers can distinguish "not a slash"
169
+ * from "slash but unknown".
170
+ */
171
+ export function parseSlash(text: string): ParsedSlash | null {
172
+ const trimmed = text.trimStart()
173
+ if (!trimmed.startsWith('/')) return null
174
+ const stripped = trimmed.slice(1).trimEnd()
175
+ if (stripped.length === 0) return null
176
+ const parts = stripped.split(/\s+/)
177
+ const name = (parts[0] ?? '').toLowerCase()
178
+ if (name.length === 0) return null
179
+ const args = parts.slice(1)
180
+ return { name, args, command: findCommand(name) }
181
+ }
182
+
183
+ /**
184
+ * Filter commands by surface and prefix for autocomplete suggestions.
185
+ * Empty or single-`/` query returns all commands for the surface.
186
+ */
187
+ export function suggestForPrefix(surface: CommandSurface, query: string): SlashCommand[] {
188
+ const stripped = query.replace(/^\/+/, '').toLowerCase()
189
+ const all = commandsForSurface(surface)
190
+ if (stripped.length === 0) return all
191
+ return all.filter(c => c.name.startsWith(stripped))
192
+ }
193
+
194
+ /**
195
+ * Permission modes operators can flip via /yolo and /perms. Matches
196
+ * `PermissionMode` from `../permission` but redeclared here to avoid the
197
+ * commands module taking a transitive dependency on the permission service.
198
+ */
199
+ export type PermissionToggleMode = 'off' | 'prompt' | 'strict'
200
+
201
+ export interface PermissionApi {
202
+ getMode(): PermissionToggleMode
203
+ setMode(mode: PermissionToggleMode): void
204
+ }
205
+
206
+ export interface ApplyResult {
207
+ /** Operator-facing message describing the new state. */
208
+ message: string
209
+ /** Mode after the operation, for callers that mirror UI state. */
210
+ mode: PermissionToggleMode
211
+ }
212
+
213
+ /**
214
+ * Toggle approval prompts between `off` (YOLO) and `prompt` (re-enable).
215
+ * `strict` flips to `prompt` on toggle to give an off-ramp from lockdown.
216
+ * Single source of truth; consumed by TUI `/yolo`, TG-local `/yolo`, and
217
+ * gateway TG `/yolo` so wording stays in lockstep.
218
+ */
219
+ export function applyYolo(permission: PermissionApi): ApplyResult {
220
+ const cur = permission.getMode()
221
+ const next: PermissionToggleMode = cur === 'off' ? 'prompt' : 'off'
222
+ permission.setMode(next)
223
+ return {
224
+ mode: next,
225
+ message:
226
+ next === 'off'
227
+ ? 'YOLO ON. Approval prompts disabled for this session.'
228
+ : 'YOLO OFF. Approvals back on.',
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Set the permission mode explicitly. `arg === undefined` returns the current
234
+ * mode without mutation. Returns the same `{ message, mode }` shape so callers
235
+ * have one branch instead of two for "show vs set".
236
+ */
237
+ export function applyPerms(
238
+ permission: PermissionApi,
239
+ arg: string | undefined,
240
+ ): ApplyResult | { message: string; mode: PermissionToggleMode; error: true } {
241
+ if (!arg) {
242
+ const mode = permission.getMode()
243
+ return { mode, message: `perms: ${mode}` }
244
+ }
245
+ const lower = arg.toLowerCase()
246
+ if (lower !== 'off' && lower !== 'prompt' && lower !== 'strict') {
247
+ return {
248
+ mode: permission.getMode(),
249
+ message: `unknown perms mode '${arg}'. try: off | prompt | strict`,
250
+ error: true,
251
+ }
252
+ }
253
+ permission.setMode(lower as PermissionToggleMode)
254
+ return { mode: lower as PermissionToggleMode, message: `perms set to ${lower}` }
255
+ }