zooid 0.7.1 → 0.7.3

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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Zoon</title>
8
- <script type="module" crossorigin src="/assets/index-Cz5fEZBc.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-C2MAIew3.css">
8
+ <script type="module" crossorigin src="/assets/index-BqOX0Pv4.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C-ZtBp7U.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Open-source tool for defining and running AI agents alongside you and your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,13 +37,13 @@
37
37
  "marked": "^18.0.4",
38
38
  "sanitize-html": "^2.17.4",
39
39
  "yaml": "^2.5.0",
40
- "@zooid/acp-client": "^0.7.1",
41
- "@zooid/context-mcp": "^0.7.1",
42
- "@zooid/core": "^0.7.1",
43
- "@zooid/runtime-docker": "^0.7.1",
44
- "@zooid/runtime-local": "^0.7.1",
45
- "@zooid/transport-http": "^0.7.1",
46
- "@zooid/transport-matrix": "^0.7.1"
40
+ "@zooid/context-mcp": "^0.7.3",
41
+ "@zooid/acp-client": "^0.7.3",
42
+ "@zooid/transport-http": "^0.7.3",
43
+ "@zooid/runtime-docker": "^0.7.3",
44
+ "@zooid/transport-matrix": "^0.7.3",
45
+ "@zooid/runtime-local": "^0.7.3",
46
+ "@zooid/core": "^0.7.3"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@agentclientprotocol/sdk": "^0.21.0",
@@ -26,7 +26,7 @@ const matrixCfg: ZooidConfig = {
26
26
  matrix: {
27
27
  transport: 'mx',
28
28
  user_id: '@architect:hs',
29
- rooms: ['!r:hs'],
29
+ rooms: [{ alias: '!r:hs' }],
30
30
  trigger: 'mention',
31
31
  },
32
32
  },
@@ -28,7 +28,7 @@ function makeCfg(over: Partial<ZooidConfig> = {}): ZooidConfig {
28
28
  matrix: {
29
29
  transport: 'm1',
30
30
  user_id: '@alice:localhost',
31
- rooms: ['!r:localhost'],
31
+ rooms: [{ alias: '!r:localhost' }],
32
32
  trigger: 'mention',
33
33
  },
34
34
  container: { env: { LOG_LEVEL: 'info' } },
@@ -27,7 +27,7 @@ function mkCfg(over: Partial<ZooidConfig['agents']['alice']> = {}): ZooidConfig
27
27
  matrix: {
28
28
  transport: 'm1',
29
29
  user_id: '@alice:localhost',
30
- rooms: ['!r:localhost'],
30
+ rooms: [{ alias: '!r:localhost' }],
31
31
  trigger: 'mention',
32
32
  },
33
33
  ...over,
@@ -207,7 +207,7 @@ describe('buildAcpRegistry — image resolution (ZOD044)', () => {
207
207
  matrix: {
208
208
  transport: 'm1',
209
209
  user_id: '@bob:localhost',
210
- rooms: ['!r:localhost'],
210
+ rooms: [{ alias: '!r:localhost' }],
211
211
  trigger: 'mention',
212
212
  },
213
213
  },
@@ -161,7 +161,11 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
161
161
  title: 'Wait for Tuwunel /_matrix/client/versions',
162
162
  task: async () => {
163
163
  if (!ctx.svc) throw new Error('service not started')
164
- await ctx.svc.waitHealthy({ url: homeserver, timeoutMs: 60_000 })
164
+ // 180s covers Docker Desktop's first-run bind-mount setup on macOS
165
+ // (which can sit in `created` for 30–90s before transitioning to
166
+ // `running`) plus Tuwunel's RocksDB init. Repeated runs against an
167
+ // existing DB are far quicker.
168
+ await ctx.svc.waitHealthy({ url: homeserver, timeoutMs: 180_000 })
165
169
  },
166
170
  },
167
171
  {
@@ -18,6 +18,7 @@ import { createApp } from '@zooid/transport-http'
18
18
  import {
19
19
  MatrixClient,
20
20
  createMatrixTransport,
21
+ ensureDefaultChannel,
21
22
  ensureWorkforceSpace,
22
23
  startWorkforcePublisher,
23
24
  type AgentBinding,
@@ -174,7 +175,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
174
175
  hsToken: matrix.transport.hs_token,
175
176
  adminUserId: opts.adminUserId,
176
177
  })
177
- const requestedPort = matrix.transport.port ?? 8080
178
+ const requestedPort = matrix.transport.port ?? 9000
178
179
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
179
180
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
180
181
  // events back to host.docker.internal:<port>.
@@ -189,6 +190,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
189
190
  new URL(matrix.transport.homeserver).hostname
190
191
  const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
191
192
  const spaceLocalpart = matrix.transport.space ?? 'dev'
193
+ const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
192
194
  let spaceRoomId: string | undefined
193
195
  try {
194
196
  spaceRoomId = await ensureWorkforceSpace({
@@ -197,6 +199,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
197
199
  serverName,
198
200
  spaceLocalpart,
199
201
  preset: 'public_chat',
202
+ admins: adminUserIds,
200
203
  })
201
204
  console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
202
205
  } catch (err) {
@@ -209,9 +212,21 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
209
212
  // those pushes don't hit a connection-refused. Bootstrap also runs
210
213
  // *after* the space exists so BotPool can attach each agent room as
211
214
  // m.space.child while joining it.
212
- await transport.bootstrap({ spaceRoomId, asUserId })
215
+ await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
213
216
 
214
217
  if (spaceRoomId) {
218
+ try {
219
+ const generalRoomId = await ensureDefaultChannel({
220
+ client,
221
+ asUserId,
222
+ serverName,
223
+ spaceId: spaceRoomId,
224
+ admins: adminUserIds,
225
+ })
226
+ console.log(`[matrix] ensured default channel #general:${serverName} → ${generalRoomId}`)
227
+ } catch (err) {
228
+ console.warn('[matrix] default channel provisioning failed:', err)
229
+ }
215
230
  try {
216
231
  const publisher: PublisherHandle = await startWorkforcePublisher({
217
232
  client,
@@ -1,6 +1,10 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
2
4
  import type { Paths } from '../bootstrap/paths.js'
3
5
 
6
+ const execFileAsync = promisify(execFile)
7
+
4
8
  export interface TuwunelOpts {
5
9
  name: string
6
10
  image?: string
@@ -61,7 +65,32 @@ export class TuwunelService {
61
65
  }
62
66
 
63
67
  async waitHealthy(opts: { url: string; timeoutMs: number }): Promise<void> {
68
+ // Two-phase wait: first the container has to transition from `created` to
69
+ // `running` (Docker Desktop on macOS can take 30–90s on first-run bind-mount
70
+ // setup), then Tuwunel has to open its DB and start serving HTTP. Polling
71
+ // only HTTP loses both signals: a long create looks identical to a long
72
+ // boot, and a crashed container looks identical to a slow one.
64
73
  const deadline = Date.now() + opts.timeoutMs
74
+
75
+ // Phase 1: wait for the container to be running. Surface the actual
76
+ // docker error if it transitions to `exited` instead — that's the
77
+ // case where today we just hang for 60s and say "did not become healthy"
78
+ // with no clue what really broke.
79
+ while (Date.now() < deadline) {
80
+ const state = await this.inspectState().catch(() => null)
81
+ if (state?.status === 'running') break
82
+ if (state?.status === 'exited') {
83
+ throw new Error(
84
+ `Tuwunel container exited before serving HTTP ` +
85
+ `(exit=${state.exitCode}${state.error ? `, error=${state.error}` : ''}). ` +
86
+ `Check the engine logs (\`${this.opts.engine} logs ${this.opts.name}\`).`,
87
+ )
88
+ }
89
+ await new Promise((resolve) => setTimeout(resolve, 500))
90
+ }
91
+
92
+ // Phase 2: container is running (or we ran out of time). Poll the HTTP
93
+ // endpoint until either it answers or the deadline passes.
65
94
  while (Date.now() < deadline) {
66
95
  try {
67
96
  const r = await fetch(`${opts.url}/_matrix/client/versions`)
@@ -71,10 +100,48 @@ export class TuwunelService {
71
100
  }
72
101
  await new Promise((resolve) => setTimeout(resolve, 500))
73
102
  }
103
+
104
+ // Last-ditch diagnostic before throwing the generic timeout — surface the
105
+ // current container state so the user knows whether to blame slow start,
106
+ // slow boot, or a misconfiguration.
107
+ const finalState = await this.inspectState().catch(() => null)
108
+ const detail = finalState
109
+ ? ` (container status=${finalState.status}${
110
+ finalState.exitCode !== undefined ? `, exit=${finalState.exitCode}` : ''
111
+ })`
112
+ : ''
74
113
  throw new Error(
75
- `Tuwunel did not become healthy at ${opts.url} in ${opts.timeoutMs}ms`,
114
+ `Tuwunel did not become healthy at ${opts.url} in ${opts.timeoutMs}ms${detail}`,
76
115
  )
77
116
  }
117
+
118
+ /**
119
+ * Read the engine's view of the container. Returns null if the container
120
+ * isn't known to the engine (e.g. before spawn completed or after `--rm`).
121
+ */
122
+ private async inspectState(): Promise<{
123
+ status: string
124
+ exitCode?: number
125
+ error?: string
126
+ } | null> {
127
+ try {
128
+ const { stdout } = await execFileAsync(this.opts.engine, [
129
+ 'inspect',
130
+ this.opts.name,
131
+ '--format',
132
+ '{{.State.Status}}|{{.State.ExitCode}}|{{.State.Error}}',
133
+ ])
134
+ const [status, exitCodeRaw, error] = stdout.trim().split('|')
135
+ const exitCode = exitCodeRaw && exitCodeRaw !== '<no value>' ? Number(exitCodeRaw) : undefined
136
+ return {
137
+ status: status ?? 'unknown',
138
+ ...(Number.isFinite(exitCode) ? { exitCode: exitCode as number } : {}),
139
+ ...(error ? { error } : {}),
140
+ }
141
+ } catch {
142
+ return null
143
+ }
144
+ }
78
145
  }
79
146
 
80
147
  function execEngine(engine: string, args: string[]): Promise<void> {