@soederpop/luca 0.0.2 → 0.0.4

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 (113) hide show
  1. package/AGENTS.md +98 -0
  2. package/CLAUDE.md +27 -0
  3. package/SPEC.md +304 -0
  4. package/bun.lock +110 -265
  5. package/docs/CLI.md +1 -1
  6. package/docs/apis/features/node/content-db.md +16 -0
  7. package/docs/apis/features/node/fs.md +24 -0
  8. package/docs/apis/features/node/ipc-socket.md +0 -1
  9. package/docs/apis/features/node/package-finder.md +1 -11
  10. package/docs/apis/features/node/proc.md +0 -41
  11. package/docs/apis/features/node/ui.md +0 -2
  12. package/package.json +12 -8
  13. package/src/agi/container.server.ts +16 -3
  14. package/src/agi/features/assistant.ts +3 -7
  15. package/src/agi/features/assistants-manager.ts +3 -7
  16. package/src/agi/features/claude-code.ts +3 -7
  17. package/src/agi/features/conversation-history.ts +3 -7
  18. package/src/agi/features/conversation.ts +4 -8
  19. package/src/agi/features/openai-codex.ts +3 -7
  20. package/src/agi/features/openapi.ts +4 -2
  21. package/src/agi/features/skills-library.ts +4 -8
  22. package/src/cli/cli.ts +22 -0
  23. package/src/client.ts +69 -26
  24. package/src/clients/civitai/index.ts +3 -7
  25. package/src/clients/comfyui/index.ts +5 -9
  26. package/src/clients/elevenlabs/index.ts +39 -19
  27. package/src/clients/openai/index.ts +3 -7
  28. package/src/clients/supabase/index.ts +4 -13
  29. package/src/commands/console.ts +0 -3
  30. package/src/commands/eval.ts +1 -1
  31. package/src/commands/index.ts +1 -0
  32. package/src/commands/introspect.ts +128 -0
  33. package/src/commands/prompt.ts +1 -4
  34. package/src/commands/run.ts +6 -13
  35. package/src/commands/sandbox-mcp.ts +1 -13
  36. package/src/feature.ts +45 -2
  37. package/src/introspection/generated.agi.ts +175 -101
  38. package/src/introspection/generated.node.ts +175 -101
  39. package/src/introspection/generated.web.ts +113 -29
  40. package/src/introspection/index.ts +1 -1
  41. package/src/introspection/scan.ts +3 -1
  42. package/src/node/features/container-link.ts +3 -2
  43. package/src/node/features/content-db.ts +10 -2
  44. package/src/node/features/disk-cache.ts +3 -4
  45. package/src/node/features/dns.ts +3 -2
  46. package/src/node/features/docker.ts +3 -2
  47. package/src/node/features/downloader.ts +3 -16
  48. package/src/node/features/esbuild.ts +3 -12
  49. package/src/node/features/file-manager.ts +3 -2
  50. package/src/node/features/fs.ts +12 -3
  51. package/src/node/features/git.ts +3 -2
  52. package/src/node/features/google-auth.ts +3 -2
  53. package/src/node/features/google-calendar.ts +3 -2
  54. package/src/node/features/google-docs.ts +3 -2
  55. package/src/node/features/google-drive.ts +3 -2
  56. package/src/node/features/google-sheets.ts +3 -2
  57. package/src/node/features/grep.ts +3 -2
  58. package/src/node/features/helpers.ts +13 -2
  59. package/src/node/features/ink.ts +3 -3
  60. package/src/node/features/ipc-socket.ts +3 -3
  61. package/src/node/features/json-tree.ts +3 -21
  62. package/src/node/features/launcher-app-command-listener.ts +3 -2
  63. package/src/node/features/networking.ts +3 -2
  64. package/src/node/features/nlp.ts +3 -2
  65. package/src/node/features/opener.ts +8 -7
  66. package/src/node/features/os.ts +3 -2
  67. package/src/node/features/package-finder.ts +3 -2
  68. package/src/node/features/port-exposer.ts +3 -4
  69. package/src/node/features/postgres.ts +3 -3
  70. package/src/node/features/proc.ts +37 -64
  71. package/src/node/features/process-manager.ts +3 -2
  72. package/src/node/features/python.ts +3 -3
  73. package/src/node/features/repl.ts +3 -2
  74. package/src/node/features/runpod.ts +3 -3
  75. package/src/node/features/secure-shell.ts +3 -2
  76. package/src/node/features/semantic-search.ts +4 -6
  77. package/src/node/features/sqlite.ts +3 -3
  78. package/src/node/features/telegram.ts +3 -2
  79. package/src/node/features/tts.ts +3 -2
  80. package/src/node/features/ui.ts +3 -3
  81. package/src/node/features/vault.ts +3 -14
  82. package/src/node/features/vm.ts +41 -3
  83. package/src/node/features/window-manager.ts +165 -22
  84. package/src/node/features/yaml-tree.ts +3 -4
  85. package/src/node/features/yaml.ts +3 -2
  86. package/src/registry.ts +1 -1
  87. package/src/scaffolds/generated.ts +1 -1
  88. package/src/server.ts +43 -0
  89. package/src/servers/express.ts +24 -8
  90. package/src/servers/mcp.ts +2 -6
  91. package/src/servers/socket.ts +22 -7
  92. package/src/web/clients/socket.ts +3 -5
  93. package/src/web/features/asset-loader.ts +20 -12
  94. package/src/web/features/container-link.ts +3 -6
  95. package/src/web/features/esbuild.ts +21 -7
  96. package/src/web/features/helpers.ts +4 -2
  97. package/src/web/features/network.ts +24 -7
  98. package/src/web/features/speech.ts +24 -7
  99. package/src/web/features/vault.ts +21 -3
  100. package/src/web/features/vm.ts +20 -13
  101. package/src/web/features/voice-recognition.ts +26 -9
  102. package/commands/update-introspection.ts +0 -67
  103. package/docs/ideas/class-registration-refactor-possibilities.md +0 -197
  104. package/docs/ideas/container-use-api.md +0 -9
  105. package/docs/ideas/easy-auth-for-express-servers-and-luca-serve.md +0 -0
  106. package/docs/ideas/feature-stacks.md +0 -22
  107. package/docs/ideas/luca-cli-self-sufficiency-demo.md +0 -23
  108. package/docs/ideas/mcp-design.md +0 -9
  109. package/docs/ideas/web-container-debugging-feature.md +0 -13
  110. package/scripts/animations/chrome-glitch.ts +0 -55
  111. package/scripts/animations/index.ts +0 -16
  112. package/scripts/animations/neon-pulse.ts +0 -64
  113. package/scripts/animations/types.ts +0 -6
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from "../feature.js";
3
+ import { Feature } from "../feature.js";
4
4
  import vm from 'vm'
5
5
  import readline from 'readline'
6
6
  import { displayResult } from '../../commands/eval.js'
@@ -36,6 +36,7 @@ export class Repl<
36
36
  static override shortcut = "features.repl" as const
37
37
  static override stateSchema = ReplStateSchema
38
38
  static override optionsSchema = ReplOptionsSchema
39
+ static { Feature.register(this, 'repl') }
39
40
 
40
41
  /** Whether the REPL session is currently running. */
41
42
  get isStarted() {
@@ -191,4 +192,4 @@ export class Repl<
191
192
  }
192
193
  }
193
194
 
194
- export default features.register("repl", Repl);
195
+ export default Repl
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from '../feature'
3
+ import { Feature } from '../feature'
4
4
  import axios from 'axios'
5
5
 
6
6
  export const RunpodStateSchema = FeatureStateSchema.extend({})
@@ -35,6 +35,7 @@ export class Runpod extends Feature<RunpodState, RunpodOptions> {
35
35
  static override envVars = ['RUNPOD_API_KEY']
36
36
  static override stateSchema = RunpodStateSchema
37
37
  static override optionsSchema = RunpodOptionsSchema
38
+ static { Feature.register(this, 'runpod') }
38
39
 
39
40
  /** The proc feature used for executing CLI commands like runpodctl. */
40
41
  get proc() {
@@ -607,8 +608,7 @@ export class Runpod extends Feature<RunpodState, RunpodOptions> {
607
608
 
608
609
  }
609
610
 
610
- export default features.register('runpod', Runpod)
611
-
611
+ export default Runpod
612
612
  /** Shell-escape a string for safe use in SSH commands */
613
613
  function esc(s: string): string {
614
614
  return `'${s.replace(/'/g, "'\\''")}'`
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from '../feature.js'
3
+ import { Feature } from '../feature.js'
4
4
 
5
5
  export const SecureShellStateSchema = FeatureStateSchema.extend({
6
6
  /** Whether an SSH connection is currently active */
@@ -49,6 +49,7 @@ export class SecureShell extends Feature<SecureShellState, SecureShellOptions> {
49
49
  static override shortcut = 'features.secureShell' as const
50
50
  static override stateSchema = SecureShellStateSchema
51
51
  static override optionsSchema = SecureShellOptionsSchema
52
+ static { Feature.register(this, 'secureShell') }
52
53
 
53
54
  override get initialState(): SecureShellState {
54
55
  return {
@@ -245,4 +246,4 @@ export class SecureShell extends Feature<SecureShellState, SecureShellOptions> {
245
246
  }
246
247
  }
247
248
 
248
- export default features.register('secureShell', SecureShell)
249
+ export default SecureShell
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
- import type { Container } from '@soederpop/luca/container'
4
3
  import { type AvailableFeatures } from '@soederpop/luca/feature'
5
- import { features, Feature } from '../feature.js'
4
+ import { Feature } from '../feature.js'
6
5
  import { Database } from 'bun:sqlite'
7
6
  import { createHash } from 'node:crypto'
8
7
  import { mkdirSync, existsSync, statSync } from 'node:fs'
@@ -275,6 +274,7 @@ export class SemanticSearch extends Feature<SemanticSearchState, SemanticSearchO
275
274
  static override stateSchema = SemanticSearchStateSchema
276
275
  static override optionsSchema = SemanticSearchOptionsSchema
277
276
  static override shortcut = 'features.semanticSearch' as const
277
+ static { Feature.register(this, 'semanticSearch') }
278
278
 
279
279
  private _db: Database | null = null
280
280
  private _llamaContext: any = null
@@ -283,10 +283,6 @@ export class SemanticSearch extends Feature<SemanticSearchState, SemanticSearchO
283
283
  private _idleTimer: ReturnType<typeof setTimeout> | null = null
284
284
  private _dimensions: number
285
285
 
286
- static attach(container: Container<AvailableFeatures, any>) {
287
- features.register('semanticSearch', SemanticSearch)
288
- return container
289
- }
290
286
 
291
287
  override get initialState(): SemanticSearchState {
292
288
  return {
@@ -922,3 +918,5 @@ export class SemanticSearch extends Feature<SemanticSearchState, SemanticSearchO
922
918
  this.state.set('dbReady', false)
923
919
  }
924
920
  }
921
+
922
+ export default SemanticSearch
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { Database } from 'bun:sqlite'
3
- import { Feature, features } from '../feature.js'
3
+ import { Feature } from '../feature.js'
4
4
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
5
5
  import type { ContainerContext } from '../../container.js'
6
6
 
@@ -69,6 +69,7 @@ export class Sqlite extends Feature<SqliteState, SqliteOptions> {
69
69
  static override stateSchema = SqliteStateSchema
70
70
  static override optionsSchema = SqliteOptionsSchema
71
71
  static override eventsSchema = SqliteEventsSchema
72
+ static { Feature.register(this, 'sqlite') }
72
73
 
73
74
  private _db: Database
74
75
 
@@ -247,8 +248,7 @@ export class Sqlite extends Feature<SqliteState, SqliteOptions> {
247
248
  }
248
249
  }
249
250
 
250
- export default features.register('sqlite', Sqlite)
251
-
251
+ export default Sqlite
252
252
  declare module '../../feature.js' {
253
253
  interface AvailableFeatures {
254
254
  sqlite: typeof Sqlite
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from '../feature.js'
3
+ import { Feature } from '../feature.js'
4
4
  import { Bot, webhookCallback, type Context, type Middleware } from 'grammy'
5
5
 
6
6
  type UserFromGetMe = Awaited<ReturnType<Bot['api']['getMe']>>
@@ -80,6 +80,7 @@ export class Telegram extends Feature<TelegramState, TelegramOptions> {
80
80
  static override stateSchema = TelegramStateSchema
81
81
  static override optionsSchema = TelegramOptionsSchema
82
82
  static override eventsSchema = TelegramEventsSchema
83
+ static { Feature.register(this, 'telegram') }
83
84
 
84
85
  private _bot?: Bot
85
86
 
@@ -339,4 +340,4 @@ export class Telegram extends Feature<TelegramState, TelegramOptions> {
339
340
 
340
341
  }
341
342
 
342
- export default features.register('telegram', Telegram)
343
+ export default Telegram
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from '../feature.js'
3
+ import { Feature } from '../feature.js'
4
4
  import { createHash } from 'crypto'
5
5
 
6
6
  const CHATTERBOX_ENDPOINT = 'https://api.runpod.ai/v2/chatterbox-turbo/runsync'
@@ -61,6 +61,7 @@ export class TTS extends Feature<TTSState, TTSOptions> {
61
61
  static override stateSchema = TTSStateSchema
62
62
  static override optionsSchema = TTSOptionsSchema
63
63
  static override eventsSchema = TTSEventsSchema
64
+ static { Feature.register(this, 'tts') }
64
65
 
65
66
  /** RunPod API key from options or environment. */
66
67
  get apiKey(): string {
@@ -181,4 +182,4 @@ export class TTS extends Feature<TTSState, TTSOptions> {
181
182
  }
182
183
  }
183
184
 
184
- export default features.register('tts', TTS)
185
+ export default TTS
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema } from '../../schemas/base.js'
3
- import { features, Feature } from "../feature.js";
3
+ import { Feature } from "../feature.js";
4
4
  import colors from "chalk";
5
5
  import type { Fonts } from "figlet";
6
6
 
@@ -119,6 +119,7 @@ type ColoredPrintFunction = PrintFunction & {
119
119
  * ```
120
120
  */
121
121
  export class UI<T extends UIState = UIState> extends Feature<T> {
122
+ static { Feature.register(this, 'ui') }
122
123
  /** The shortcut path for accessing this feature */
123
124
  static override shortcut = "features.ui" as const
124
125
  static override stateSchema = UIStateSchema
@@ -819,8 +820,7 @@ export class UI<T extends UIState = UIState> extends Feature<T> {
819
820
  }
820
821
  }
821
822
 
822
- export default features.register("ui", UI);
823
-
823
+ export default UI
824
824
  /**
825
825
  * Predefined color palette for automatic color assignment.
826
826
  *
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
3
  import crypto from 'node:crypto'
4
- import { Feature, features } from '../feature.js'
5
- import { type NodeContainer } from '../container.js'
4
+ import { Feature } from '../feature.js'
6
5
  import { type ContainerContext } from '../../container.js'
7
6
 
8
7
  export const VaultStateSchema = FeatureStateSchema.extend({
@@ -43,17 +42,8 @@ export class Vault extends Feature<VaultState, VaultOptions> {
43
42
  static override shortcut = 'features.vault' as const
44
43
  static override stateSchema = VaultStateSchema
45
44
  static override optionsSchema = VaultOptionsSchema
45
+ static { Feature.register(this, 'vault') }
46
46
 
47
- /**
48
- * Attach hook for the Vault feature. Currently a no-op placeholder
49
- * for future container-level initialization logic.
50
- *
51
- * @param c - The node container instance
52
- */
53
- static attach(c: NodeContainer) {
54
-
55
- }
56
-
57
47
  constructor(options: VaultOptions, context: ContainerContext) {
58
48
  let secret = options.secret
59
49
 
@@ -136,8 +126,7 @@ export class Vault extends Feature<VaultState, VaultOptions> {
136
126
  }
137
127
  }
138
128
 
139
- export default features.register('vault', Vault)
140
-
129
+ export default Vault
141
130
  function generateSecretKey(): Buffer {
142
131
  return crypto.randomBytes(32);
143
132
  }
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import { createRequire } from 'module'
3
3
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
4
4
  import vm from 'vm'
5
- import { Feature, features } from "../feature.js";
5
+ import { Feature } from "../feature.js";
6
6
 
7
7
  export const VMStateSchema = FeatureStateSchema.extend({})
8
8
  export type VMState = z.infer<typeof VMStateSchema>
@@ -45,6 +45,7 @@ export class VM<
45
45
  static override shortcut = "features.vm" as const
46
46
  static override stateSchema = VMStateSchema
47
47
  static override optionsSchema = VMOptionsSchema
48
+ static { Feature.register(this, 'vm') }
48
49
 
49
50
  /** Map of virtual module IDs to their exports, consulted before Node's native require */
50
51
  modules: Map<string, any> = new Map()
@@ -193,8 +194,45 @@ export class VM<
193
194
  * }
194
195
  * ```
195
196
  */
197
+ /**
198
+ * Wrap code containing top-level `await` in an async IIFE, injecting
199
+ * `return` before the last expression so the value is not lost.
200
+ *
201
+ * If the code does not contain `await`, or is already wrapped in an
202
+ * async function/arrow, it is returned unchanged.
203
+ */
204
+ wrapTopLevelAwait(code: string): string {
205
+ if (!/\bawait\b/.test(code) || /^\s*\(?\s*async\b/.test(code)) {
206
+ return code
207
+ }
208
+
209
+ const lines = code.split('\n')
210
+
211
+ // Find the last non-empty line
212
+ let lastIdx = lines.length - 1
213
+ while (lastIdx > 0 && !lines[lastIdx].trim()) lastIdx--
214
+
215
+ let lastLine = lines[lastIdx]!
216
+
217
+ // For single-line code with semicolons (e.g. CLI eval), split the last line
218
+ // into statements and only try to return the final statement.
219
+ const stmts = lastLine.split(';').map(s => s.trim()).filter(Boolean)
220
+ if (stmts.length > 1) {
221
+ const finalStmt = stmts[stmts.length - 1]!
222
+ if (!/^\s*(var|let|const|if|for|while|switch|try|throw|class|function|return)\b/.test(finalStmt)) {
223
+ stmts[stmts.length - 1] = `return ${finalStmt}`
224
+ }
225
+ lines[lastIdx] = stmts.join('; ')
226
+ } else if (!/^\s*(var|let|const|if|for|while|switch|try|throw|class|function|return)\b/.test(lastLine)) {
227
+ lines[lastIdx] = `return ${lastLine}`
228
+ }
229
+
230
+ return `(async () => {\n${lines.join('\n')}\n})()`
231
+ }
232
+
196
233
  async run<T extends any>(code: string, ctx: any = {}): Promise<T> {
197
- const script = this.createScript(code)
234
+ const wrapped = this.wrapTopLevelAwait(code)
235
+ const script = this.createScript(wrapped)
198
236
  const context = this.isContext(ctx) ? ctx : this.createContext(ctx)
199
237
 
200
238
  return (await script.runInContext(context)) as T
@@ -309,4 +347,4 @@ export class VM<
309
347
  }
310
348
  }
311
349
 
312
- export default features.register("vm", VM);
350
+ export default VM
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from '../feature.js'
3
+ import { Feature } from '../feature.js'
4
+ import { Bus } from '../../bus.js'
4
5
  import { Server as NetServer, Socket } from 'net'
5
6
  import { homedir } from 'os'
6
7
  import { join, dirname } from 'path'
@@ -154,17 +155,76 @@ export interface WindowAckResult {
154
155
  [key: string]: any
155
156
  }
156
157
 
158
+ // --- Layout Types ---
159
+
160
+ /**
161
+ * A single entry in a layout configuration.
162
+ * Use `type: 'tty'` for terminal windows, `type: 'window'` (or omit) for browser windows.
163
+ * If `type` is omitted, entries with a `command` field are treated as TTY, otherwise as window.
164
+ */
165
+ export type LayoutEntry =
166
+ | ({ type: 'window' } & SpawnOptions)
167
+ | ({ type: 'tty' } & SpawnTTYOptions)
168
+ | ({ type?: undefined } & SpawnOptions)
169
+ | ({ type?: undefined; command: string } & SpawnTTYOptions)
170
+
171
+ // --- WindowHandle Events ---
172
+
173
+ interface WindowHandleEvents {
174
+ close: [msg: any]
175
+ terminalExited: [msg: any]
176
+ }
177
+
157
178
  // --- WindowHandle ---
158
179
 
159
180
  /**
160
181
  * A lightweight handle to a single native window.
161
182
  * Delegates all operations back to the WindowManager instance.
183
+ * Emits lifecycle events (`close`, `terminalExited`) when the native app reports them.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const handle = await windowManager.spawn({ url: 'https://example.com' })
188
+ * handle.on('close', (msg) => console.log('window closed', msg))
189
+ * handle.on('terminalExited', (info) => console.log('process exited', info))
190
+ * ```
162
191
  */
163
192
  export class WindowHandle {
193
+ private _events = new Bus<WindowHandleEvents>()
194
+
195
+ /** The original ack result from spawning this window. */
196
+ public result: WindowAckResult
197
+
164
198
  constructor(
165
199
  public readonly windowId: string,
166
- private manager: WindowManager
167
- ) {}
200
+ private manager: WindowManager,
201
+ result?: WindowAckResult
202
+ ) {
203
+ this.result = result ?? {}
204
+ }
205
+
206
+ /** Register a listener for a lifecycle event. */
207
+ on<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
208
+ this._events.on(event, listener)
209
+ return this
210
+ }
211
+
212
+ /** Remove a listener for a lifecycle event. */
213
+ off<E extends keyof WindowHandleEvents>(event: E, listener?: (...args: WindowHandleEvents[E]) => void): this {
214
+ this._events.off(event, listener)
215
+ return this
216
+ }
217
+
218
+ /** Register a one-time listener for a lifecycle event. */
219
+ once<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
220
+ this._events.once(event, listener)
221
+ return this
222
+ }
223
+
224
+ /** Emit a lifecycle event on this handle. */
225
+ emit<E extends keyof WindowHandleEvents>(event: E, ...args: WindowHandleEvents[E]): void {
226
+ this._events.emit(event, ...args)
227
+ }
168
228
 
169
229
  /** Bring this window to the front. */
170
230
  async focus(): Promise<WindowAckResult> {
@@ -234,8 +294,8 @@ interface ClientConnection {
234
294
  * ```typescript
235
295
  * const wm = container.feature('windowManager', { enable: true, autoListen: true })
236
296
  *
237
- * const result = await wm.spawn({ url: 'https://google.com', width: 800, height: 600 })
238
- * const handle = wm.window(result.windowId)
297
+ * const handle = await wm.spawn({ url: 'https://google.com', width: 800, height: 600 })
298
+ * handle.on('close', (msg) => console.log('window closed'))
239
299
  * await handle.navigate('https://news.ycombinator.com')
240
300
  * const title = await handle.eval('document.title')
241
301
  * await handle.close()
@@ -252,11 +312,13 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
252
312
  static override stateSchema = WindowManagerStateSchema
253
313
  static override optionsSchema = WindowManagerOptionsSchema
254
314
  static override eventsSchema = WindowManagerEventsSchema
315
+ static { Feature.register(this, 'windowManager') }
255
316
 
256
317
  private _server?: NetServer
257
318
  private _client?: ClientConnection
258
319
  private _pending = new Map<string, PendingRequest>()
259
320
  private _trackedWindows = new Set<string>()
321
+ private _handles = new Map<string, WindowHandle>()
260
322
 
261
323
  private normalizeRequestId(value: unknown): string | undefined {
262
324
  if (typeof value !== 'string') return undefined
@@ -405,6 +467,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
405
467
  }
406
468
  this._pending.clear()
407
469
  this._trackedWindows.clear()
470
+ this._handles.clear()
408
471
 
409
472
  if (this._client) {
410
473
  this._client.socket.destroy()
@@ -436,26 +499,29 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
436
499
  * Sends a window dispatch to the app and waits for the ack.
437
500
  *
438
501
  * @param opts - Window configuration (url, dimensions, chrome options)
439
- * @returns The window ack result including `windowId`
502
+ * @returns A WindowHandle for the spawned window (with `.result` containing the ack data)
440
503
  */
441
- async spawn(opts: SpawnOptions = {}): Promise<WindowAckResult> {
504
+ async spawn(opts: SpawnOptions = {}): Promise<WindowHandle> {
442
505
  const { window: windowChrome, ...flat } = opts
443
506
 
507
+ let ackResult: WindowAckResult
444
508
  if (windowChrome) {
445
- return this.sendWindowCommand({
509
+ ackResult = await this.sendWindowCommand({
446
510
  action: 'open',
447
511
  request: {
448
512
  ...flat,
449
513
  window: windowChrome,
450
514
  },
451
515
  })
516
+ } else {
517
+ ackResult = await this.sendWindowCommand({
518
+ action: 'open',
519
+ ...flat,
520
+ alwaysOnTop: flat.alwaysOnTop ?? false,
521
+ })
452
522
  }
453
523
 
454
- return this.sendWindowCommand({
455
- action: 'open',
456
- ...flat,
457
- alwaysOnTop: flat.alwaysOnTop ?? false,
458
- })
524
+ return this.getOrCreateHandle(ackResult.windowId, ackResult)
459
525
  }
460
526
 
461
527
  /**
@@ -464,23 +530,26 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
464
530
  * Closing the window terminates the process.
465
531
  *
466
532
  * @param opts - Terminal configuration (command, args, cwd, dimensions, etc.)
467
- * @returns The window ack result including `windowId` and `pid`
533
+ * @returns A WindowHandle for the spawned terminal (with `.result` containing the ack data)
468
534
  */
469
- async spawnTTY(opts: SpawnTTYOptions): Promise<WindowAckResult> {
535
+ async spawnTTY(opts: SpawnTTYOptions): Promise<WindowHandle> {
470
536
  const { window: windowChrome, ...flat } = opts
471
537
 
538
+ let ackResult: WindowAckResult
472
539
  if (windowChrome) {
473
- return this.sendWindowCommand({
540
+ ackResult = await this.sendWindowCommand({
474
541
  action: 'terminal',
475
542
  ...flat,
476
543
  window: windowChrome,
477
544
  })
545
+ } else {
546
+ ackResult = await this.sendWindowCommand({
547
+ action: 'terminal',
548
+ ...flat,
549
+ })
478
550
  }
479
551
 
480
- return this.sendWindowCommand({
481
- action: 'terminal',
482
- ...flat,
483
- })
552
+ return this.getOrCreateHandle(ackResult.windowId, ackResult)
484
553
  }
485
554
 
486
555
  /**
@@ -573,12 +642,78 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
573
642
 
574
643
  /**
575
644
  * Get a WindowHandle for chainable operations on a specific window.
645
+ * Returns the tracked handle if one exists, otherwise creates a new one.
576
646
  *
577
647
  * @param windowId - The window ID
578
648
  * @returns A WindowHandle instance
579
649
  */
580
650
  window(windowId: string): WindowHandle {
581
- return new WindowHandle(windowId, this)
651
+ return this.getOrCreateHandle(windowId)
652
+ }
653
+
654
+ /**
655
+ * Spawn multiple windows in parallel from a layout configuration.
656
+ * Returns handles in the same order as the config entries.
657
+ *
658
+ * @param config - Array of layout entries (window or tty)
659
+ * @returns Array of WindowHandle instances
660
+ *
661
+ * @example
662
+ * ```typescript
663
+ * const handles = await wm.spawnLayout([
664
+ * { type: 'window', url: 'https://google.com', width: 800, height: 600 },
665
+ * { type: 'tty', command: 'htop' },
666
+ * { url: 'https://github.com' }, // defaults to window
667
+ * ])
668
+ * ```
669
+ */
670
+ async spawnLayout(config: LayoutEntry[]): Promise<WindowHandle[]> {
671
+ const promises = config.map(entry => {
672
+ if (entry.type === 'tty' || ('command' in entry && !entry.type)) {
673
+ const { type, ...opts } = entry as { type?: string } & SpawnTTYOptions
674
+ return this.spawnTTY(opts)
675
+ } else {
676
+ const { type, ...opts } = entry as { type?: string } & SpawnOptions
677
+ return this.spawn(opts)
678
+ }
679
+ })
680
+ return Promise.all(promises)
681
+ }
682
+
683
+ /**
684
+ * Spawn multiple layouts sequentially. Each layout's windows spawn in parallel,
685
+ * but the next layout waits for the previous one to fully complete.
686
+ *
687
+ * @param configs - Array of layout configurations
688
+ * @returns Array of handle arrays, one per layout
689
+ *
690
+ * @example
691
+ * ```typescript
692
+ * const [firstBatch, secondBatch] = await wm.spawnLayouts([
693
+ * [{ url: 'https://google.com' }, { url: 'https://github.com' }],
694
+ * [{ type: 'tty', command: 'htop' }],
695
+ * ])
696
+ * ```
697
+ */
698
+ async spawnLayouts(configs: LayoutEntry[][]): Promise<WindowHandle[][]> {
699
+ const results: WindowHandle[][] = []
700
+ for (const config of configs) {
701
+ results.push(await this.spawnLayout(config))
702
+ }
703
+ return results
704
+ }
705
+
706
+ /** Get or create a tracked WindowHandle for a given windowId. */
707
+ private getOrCreateHandle(windowId: string | undefined, result?: WindowAckResult): WindowHandle {
708
+ const id = windowId || randomUUID()
709
+ let handle = this._handles.get(id)
710
+ if (!handle) {
711
+ handle = new WindowHandle(id, this, result)
712
+ this._handles.set(id, handle)
713
+ } else if (result) {
714
+ handle.result = result
715
+ }
716
+ return handle
582
717
  }
583
718
 
584
719
  // --- Private internals ---
@@ -612,6 +747,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
612
747
  if (this._client === client) {
613
748
  this._client = undefined
614
749
  this._trackedWindows.clear()
750
+ this._handles.clear()
615
751
  this.setState({ clientConnected: false, windowCount: 0 })
616
752
 
617
753
  // Resolve all pending requests — the app is gone, no acks coming
@@ -664,10 +800,17 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
664
800
 
665
801
  if (msg.type === 'windowClosed') {
666
802
  this.trackWindowClosed(msg.windowId)
803
+ const handle = this._handles.get(msg.windowId)
804
+ if (handle) {
805
+ handle.emit('close', msg)
806
+ this._handles.delete(msg.windowId)
807
+ }
667
808
  this.emit('windowClosed', msg)
668
809
  }
669
810
 
670
811
  if (msg.type === 'terminalExited') {
812
+ const handle = msg.windowId ? this._handles.get(msg.windowId) : undefined
813
+ if (handle) handle.emit('terminalExited', msg)
671
814
  this.emit('terminalExited', msg)
672
815
  }
673
816
 
@@ -801,4 +944,4 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
801
944
  }
802
945
  }
803
946
 
804
- export default features.register('windowManager', WindowManager)
947
+ export default WindowManager
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
- import { Feature, features } from "../feature.js";
3
+ import { Feature } from "../feature.js";
4
4
  import { NodeContainer } from "../container.js";
5
5
  import { camelCase, omit, set } from 'lodash-es'
6
6
 
@@ -36,6 +36,7 @@ export type YamlTreeState = z.infer<typeof YamlTreeStateSchema>
36
36
  * @extends {Feature<T>}
37
37
  */
38
38
  export class YamlTree<T extends YamlTreeState = YamlTreeState> extends Feature<T> {
39
+ static { Feature.register(this, 'yamlTree') }
39
40
  /** The shortcut path for accessing this feature */
40
41
  static override shortcut = "features.yamlTree" as const
41
42
  static override stateSchema = YamlTreeStateSchema
@@ -49,7 +50,6 @@ export class YamlTree<T extends YamlTreeState = YamlTreeState> extends Feature<T
49
50
  * @returns The container for method chaining
50
51
  */
51
52
  static attach(container: NodeContainer & { yamlTree?: YamlTree }) {
52
- container.features.register("yamlTree", YamlTree);
53
53
  container.yamlTree = container.feature("yamlTree", { enable: true });
54
54
  }
55
55
 
@@ -145,5 +145,4 @@ export class YamlTree<T extends YamlTreeState = YamlTreeState> extends Feature<T
145
145
  }
146
146
  }
147
147
 
148
- export default features.register("yamlTree", YamlTree);
149
-
148
+ export default YamlTree
@@ -1,5 +1,5 @@
1
1
  import * as yaml from 'js-yaml'
2
- import { Feature, features } from '../feature.js'
2
+ import { Feature } from '../feature.js'
3
3
  import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
4
4
  import { NodeContainer } from '../container.js'
5
5
 
@@ -33,6 +33,7 @@ export class YAML extends Feature {
33
33
  static override shortcut = 'features.yaml' as const
34
34
  static override stateSchema = FeatureStateSchema
35
35
  static override optionsSchema = FeatureOptionsSchema
36
+ static { Feature.register(this, 'yaml') }
36
37
 
37
38
  /**
38
39
  * Automatically attaches the YAML feature to Node containers.
@@ -129,4 +130,4 @@ export class YAML extends Feature {
129
130
  }
130
131
  }
131
132
 
132
- export default features.register('yaml', YAML)
133
+ export default YAML
package/src/registry.ts CHANGED
@@ -101,7 +101,7 @@ abstract class Registry<T extends Helper> {
101
101
  throw new Error(
102
102
  `${this.scope} "${id}" is not registered.${suggestion}\n\n` +
103
103
  `To fix this, ensure the module that defines "${id}" is imported (e.g. import './${this.scope}/${id}') ` +
104
- `or registered on the container (e.g. container.use(${id[0]!.toUpperCase() + id.slice(1)})).`
104
+ `or registered on the container (e.g. container.use(${id ? id[0]!.toUpperCase() + id.slice(1) : 'MyHelper'})).`
105
105
  )
106
106
  }
107
107