typeclaw 0.1.1 → 0.1.2

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 (43) hide show
  1. package/README.md +12 -12
  2. package/package.json +1 -1
  3. package/src/agent/doctor.ts +173 -0
  4. package/src/agent/subagents.ts +24 -2
  5. package/src/bundled-plugins/backup/README.md +81 -0
  6. package/src/bundled-plugins/backup/index.ts +209 -0
  7. package/src/bundled-plugins/backup/runner.ts +231 -0
  8. package/src/bundled-plugins/backup/subagents.ts +200 -0
  9. package/src/bundled-plugins/memory/index.ts +42 -1
  10. package/src/channels/router.ts +29 -0
  11. package/src/cli/compose.ts +92 -1
  12. package/src/cli/doctor.ts +100 -0
  13. package/src/cli/index.ts +1 -0
  14. package/src/compose/doctor.ts +141 -0
  15. package/src/compose/index.ts +8 -0
  16. package/src/compose/logs.ts +32 -19
  17. package/src/config/config.ts +20 -0
  18. package/src/container/log-colors.ts +75 -0
  19. package/src/container/log-timestamps.ts +84 -0
  20. package/src/container/logs.ts +71 -5
  21. package/src/container/start.ts +23 -8
  22. package/src/cron/consumer.ts +29 -7
  23. package/src/doctor/checks.ts +426 -0
  24. package/src/doctor/commit.ts +71 -0
  25. package/src/doctor/index.ts +287 -0
  26. package/src/doctor/plugin-bridge.ts +147 -0
  27. package/src/doctor/report.ts +142 -0
  28. package/src/doctor/types.ts +87 -0
  29. package/src/init/cli-version.ts +81 -0
  30. package/src/init/dockerfile.ts +223 -25
  31. package/src/init/index.ts +18 -10
  32. package/src/plugin/hooks.ts +32 -0
  33. package/src/plugin/index.ts +7 -0
  34. package/src/plugin/manager.ts +2 -0
  35. package/src/plugin/registry.ts +32 -3
  36. package/src/plugin/types.ts +65 -0
  37. package/src/run/bundled-plugins.ts +8 -0
  38. package/src/run/index.ts +10 -5
  39. package/src/server/index.ts +103 -5
  40. package/src/shared/index.ts +3 -0
  41. package/src/shared/protocol.ts +22 -0
  42. package/src/skills/typeclaw-config/SKILL.md +1 -1
  43. package/typeclaw.schema.json +50 -0
@@ -97,6 +97,24 @@ export type SessionIdleEvent = {
97
97
  origin?: SessionOrigin
98
98
  }
99
99
 
100
+ // Brackets every `session.prompt(...)` invocation. Distinct from
101
+ // `session.start`/`session.end` (which bracket session lifetime) so that
102
+ // long-lived TUI or channel sessions, which can sit idle between turns,
103
+ // don't wedge a turn-counter forever. `origin` carries the session's origin
104
+ // so observers can exclude their own induced turns when counting (e.g. the
105
+ // backup plugin excludes `subagent: 'backup'` to avoid self-gating).
106
+ export type SessionTurnStartEvent = {
107
+ sessionId: string
108
+ agentDir: string
109
+ origin?: SessionOrigin
110
+ }
111
+
112
+ export type SessionTurnEndEvent = {
113
+ sessionId: string
114
+ agentDir: string
115
+ origin?: SessionOrigin
116
+ }
117
+
100
118
  // Provider prompt caching requires byte-identical prefixes. Mutations near the
101
119
  // end of `event.prompt` preserve cache hits across sessions; mutations near
102
120
  // the start invalidate the cache on every LLM call.
@@ -136,6 +154,8 @@ export type Hooks = {
136
154
  'session.end'?: (event: SessionEndEvent, ctx: HookContext) => Promise<void> | void
137
155
  'session.idle'?: (event: SessionIdleEvent, ctx: HookContext) => Promise<void> | void
138
156
  'session.prompt'?: (event: SessionPromptEvent, ctx: HookContext) => Promise<void> | void
157
+ 'session.turn.start'?: (event: SessionTurnStartEvent, ctx: HookContext) => Promise<void> | void
158
+ 'session.turn.end'?: (event: SessionTurnEndEvent, ctx: HookContext) => Promise<void> | void
139
159
  'tool.before'?: (event: ToolBeforeEvent, ctx: HookContext) => Promise<ToolBeforeResult> | ToolBeforeResult
140
160
  'tool.after'?: (event: ToolAfterEvent, ctx: HookContext) => Promise<void> | void
141
161
  }
@@ -164,6 +184,51 @@ export type PluginExports = {
164
184
  skills?: Record<string, PluginSkill>
165
185
  skillsDirs?: string[]
166
186
  hooks?: Hooks
187
+ doctorChecks?: Record<string, PluginDoctorCheck>
188
+ }
189
+
190
+ // `typeclaw doctor` plugin extension surface. Each check is read-only by
191
+ // default; declaring `fix.apply` opts the check into `typeclaw doctor --fix`,
192
+ // where the host serializes plugin fixes, validates their `changedPaths`
193
+ // against the agent folder, and commits the union of all fixes in a single
194
+ // commit.
195
+ export type PluginDoctorCheck = {
196
+ description: string
197
+ category?: string
198
+ run: (ctx: PluginDoctorContext) => Promise<PluginCheckResult>
199
+ }
200
+
201
+ export type PluginDoctorContext = {
202
+ readonly pluginName: string
203
+ readonly agentDir: string
204
+ readonly config: unknown
205
+ readonly logger: PluginLogger
206
+ }
207
+
208
+ export type PluginCheckStatus = 'ok' | 'warning' | 'error'
209
+
210
+ export type PluginCheckResult = {
211
+ status: PluginCheckStatus
212
+ message: string
213
+ details?: string[]
214
+ fix?: PluginFixSuggestion
215
+ }
216
+
217
+ export type PluginFixSuggestion = {
218
+ description: string
219
+ // When omitted, the fix is advisory-only. `typeclaw doctor --fix` only
220
+ // attempts to remediate checks whose suggestion includes an `apply`.
221
+ apply?: (ctx: PluginDoctorContext) => Promise<PluginFixResult>
222
+ }
223
+
224
+ export type PluginFixResult = {
225
+ // One-line description that appears in the commit body as a bullet.
226
+ summary: string
227
+ // POSIX paths relative to agentDir; the host validates each one stays
228
+ // inside agentDir before `git add`ing. Absolute paths and `..` segments
229
+ // are rejected to keep plugin fixes from staging files outside the agent
230
+ // folder. Empty array is valid (e.g. a fix that only logs).
231
+ changedPaths: string[]
167
232
  }
168
233
 
169
234
  export type DefinedPlugin<TConfig = never> = {
@@ -1,4 +1,5 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
+ import backupPlugin from '@/bundled-plugins/backup'
2
3
  import guardPlugin from '@/bundled-plugins/guard'
3
4
  import memoryPlugin from '@/bundled-plugins/memory'
4
5
  import securityPlugin from '@/bundled-plugins/security'
@@ -16,9 +17,16 @@ import type { ResolvedPlugin } from '@/plugin'
16
17
  // Letting `guard` run first would still work today since the two plugins
17
18
  // guard disjoint surfaces, but seeding the order now means future overlap
18
19
  // (e.g. a security policy on writes) blocks before guard's softer advice.
20
+ //
21
+ // `memory` is registered before `backup` so memory's dreaming commits always
22
+ // land in the same git index window before backup's commit-and-push cycle.
23
+ // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
24
+ // but if either ever holds .git/index.lock the deterministic order makes the
25
+ // contention easier to reason about.
19
26
  export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
20
27
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
21
28
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
22
29
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
30
+ { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
23
31
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
24
32
  ]
package/src/run/index.ts CHANGED
@@ -142,14 +142,15 @@ export async function startAgent({
142
142
  const entry = snap.pluginSubagentByShim.get(subagent)
143
143
  if (entry) {
144
144
  const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
145
+ const origin = {
146
+ kind: 'subagent' as const,
147
+ subagent: subagentOptions?.name ?? entry.subagentName,
148
+ parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
149
+ }
145
150
  const created = await createSessionWithDispose({
146
151
  systemPromptOverride: entry.pluginSubagent.systemPrompt,
147
152
  channelRouter: channelManager.router,
148
- origin: {
149
- kind: 'subagent',
150
- subagent: subagentOptions?.name ?? entry.subagentName,
151
- parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
152
- },
153
+ origin,
153
154
  plugins: {
154
155
  registry: snap.registry,
155
156
  hooks: snap.hooks,
@@ -167,6 +168,8 @@ export async function startAgent({
167
168
  ...created,
168
169
  hooks: snap.hooks,
169
170
  sessionId,
171
+ agentDir: cwd,
172
+ origin,
170
173
  }
171
174
  }
172
175
  return defaultCreateSessionForSubagent(subagent, subagentOptions)
@@ -221,6 +224,8 @@ export async function startAgent({
221
224
  prompt: (text) => session.prompt(text),
222
225
  dispose: () => session.dispose(),
223
226
  sessionId,
227
+ agentDir: cwd,
228
+ origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
224
229
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
225
230
  getTranscriptPath: () => sessionManager.getSessionFile(),
226
231
  }
@@ -6,6 +6,7 @@ import {
6
6
  type CreateSessionOptions,
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
+ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
9
10
  import type { SessionOrigin } from '@/agent/session-origin'
10
11
  import type { ChannelRouter } from '@/channels/router'
11
12
  import type { HookBus } from '@/plugin'
@@ -159,7 +160,7 @@ export function createServer({
159
160
 
160
161
  if (stream) {
161
162
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
162
- enqueuePrompt(ws, state, msg),
163
+ enqueuePrompt(ws, state, msg, agentDir),
163
164
  )
164
165
 
165
166
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -190,6 +191,16 @@ export function createServer({
190
191
  return
191
192
  }
192
193
 
194
+ if (msg.type === 'doctor') {
195
+ await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
196
+ return
197
+ }
198
+
199
+ if (msg.type === 'doctor_fix') {
200
+ await handleDoctorFix(ws, msg.requestId, msg.checkId, pluginRuntime, agentDir)
201
+ return
202
+ }
203
+
193
204
  if (msg.type === 'abort') {
194
205
  if (!state) return
195
206
  await state.session.abort()
@@ -215,13 +226,27 @@ export function createServer({
215
226
  return
216
227
  }
217
228
  send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
229
+ const fallbackHooks = state.runtimeSnapshot?.hooks
230
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
231
+ await fallbackHooks.runSessionTurnStart({
232
+ sessionId: state.sessionFileId,
233
+ agentDir,
234
+ origin: state.origin,
235
+ })
236
+ }
218
237
  try {
219
238
  await state.session.prompt(msg.text)
220
239
  send(ws, { type: 'done' })
221
240
  } catch (err) {
222
241
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
223
242
  }
224
- const fallbackHooks = state.runtimeSnapshot?.hooks
243
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
244
+ await fallbackHooks.runSessionTurnEnd({
245
+ sessionId: state.sessionFileId,
246
+ agentDir,
247
+ origin: state.origin,
248
+ })
249
+ }
225
250
  if (fallbackHooks !== undefined) {
226
251
  await fallbackHooks.runSessionIdle({
227
252
  sessionId: state.sessionFileId,
@@ -323,7 +348,7 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
323
348
  send(ws, { type: 'error', message: text })
324
349
  }
325
350
 
326
- function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
351
+ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir: string | undefined): void {
327
352
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
328
353
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
329
354
  const delivery: PromptDelivery = payload.delivery ?? 'queue'
@@ -339,7 +364,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
339
364
  ts: msg.ts,
340
365
  })
341
366
  pushQueueState(ws, state)
342
- void drain(ws, state)
367
+ void drain(ws, state, agentDir)
343
368
  }
344
369
 
345
370
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -360,10 +385,26 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
360
385
  }
361
386
  }
362
387
 
363
- async function drain(ws: Ws, state: SessionState): Promise<void> {
388
+ function makeTurnHookCallers(
389
+ state: SessionState,
390
+ agentDir: string | undefined,
391
+ ): { fireTurnStart: () => Promise<void>; fireTurnEnd: () => Promise<void> } {
392
+ const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
393
+ if (hooks === undefined || agentDir === undefined) {
394
+ return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
395
+ }
396
+ const event = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
397
+ return {
398
+ fireTurnStart: () => hooks.runSessionTurnStart(event),
399
+ fireTurnEnd: () => hooks.runSessionTurnEnd(event),
400
+ }
401
+ }
402
+
403
+ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined): Promise<void> {
364
404
  if (state.draining) return
365
405
  state.draining = true
366
406
  const fireIdle = makeIdleHookCaller(state)
407
+ const { fireTurnStart, fireTurnEnd } = makeTurnHookCallers(state, agentDir)
367
408
  try {
368
409
  while (state.drainQueue.length > 0) {
369
410
  const item = state.drainQueue.shift()
@@ -371,12 +412,14 @@ async function drain(ws: Ws, state: SessionState): Promise<void> {
371
412
  pushQueueState(ws, state)
372
413
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
373
414
 
415
+ await fireTurnStart()
374
416
  try {
375
417
  await state.session.prompt(item.text)
376
418
  send(ws, { type: 'done' })
377
419
  } catch (err) {
378
420
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
379
421
  }
422
+ await fireTurnEnd()
380
423
  await fireIdle()
381
424
  }
382
425
  } finally {
@@ -393,6 +436,61 @@ function pushQueueState(ws: Ws, state: SessionState): void {
393
436
  send(ws, { type: 'queue_state', pending })
394
437
  }
395
438
 
439
+ async function handleDoctor(
440
+ ws: Ws,
441
+ requestId: string,
442
+ pluginRuntime: PluginRuntime | undefined,
443
+ agentDir: string | undefined,
444
+ ): Promise<void> {
445
+ if (pluginRuntime === undefined || agentDir === undefined) {
446
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
447
+ return
448
+ }
449
+ const snapshot = pluginRuntime.get()
450
+ if (snapshot === undefined) {
451
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
452
+ return
453
+ }
454
+ try {
455
+ const checks = await runPluginDoctorChecks({ registry: snapshot.registry, agentDir })
456
+ send(ws, { type: 'doctor_result', requestId, checks })
457
+ } catch (err) {
458
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
459
+ }
460
+ }
461
+
462
+ async function handleDoctorFix(
463
+ ws: Ws,
464
+ requestId: string,
465
+ checkId: string,
466
+ pluginRuntime: PluginRuntime | undefined,
467
+ agentDir: string | undefined,
468
+ ): Promise<void> {
469
+ if (pluginRuntime === undefined || agentDir === undefined) {
470
+ send(ws, {
471
+ type: 'doctor_fix_result',
472
+ requestId,
473
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
474
+ })
475
+ return
476
+ }
477
+ const snapshot = pluginRuntime.get()
478
+ if (snapshot === undefined) {
479
+ send(ws, {
480
+ type: 'doctor_fix_result',
481
+ requestId,
482
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
483
+ })
484
+ return
485
+ }
486
+ const outcome = await runPluginDoctorFix({ registry: snapshot.registry, agentDir, checkId })
487
+ const result =
488
+ outcome.ok === true
489
+ ? { ok: true as const, checkId, summary: outcome.summary, changedPaths: outcome.changedPaths }
490
+ : { ok: false as const, checkId, error: outcome.error }
491
+ send(ws, { type: 'doctor_fix_result', requestId, result })
492
+ }
493
+
396
494
  async function handleReload(
397
495
  ws: Ws,
398
496
  reloadAll: ReloadAllFn | undefined,
@@ -1,5 +1,8 @@
1
1
  export {
2
2
  type ClientMessage,
3
+ type DoctorCheckPayload,
4
+ type DoctorFixPayload,
5
+ type DoctorRequestId,
3
6
  type PromptDelivery,
4
7
  type QueueStateItem,
5
8
  type ReloadResultPayload,
@@ -4,11 +4,31 @@ export type ReloadResultPayload =
4
4
 
5
5
  export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
6
6
 
7
+ export type DoctorRequestId = string
8
+
9
+ export type DoctorCheckPayload = {
10
+ id: string
11
+ pluginName: string
12
+ checkName: string
13
+ description: string
14
+ category: string
15
+ status: 'ok' | 'warning' | 'error'
16
+ message: string
17
+ details?: string[]
18
+ fix?: { description: string; hasApply: boolean }
19
+ }
20
+
21
+ export type DoctorFixPayload =
22
+ | { ok: true; checkId: string; summary: string; changedPaths: string[] }
23
+ | { ok: false; checkId: string; error: string }
24
+
7
25
  export type ClientMessage =
8
26
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
9
27
  | { type: 'reload'; scope?: string }
10
28
  | { type: 'abort' }
11
29
  | { type: 'queue_cancel'; messageId: string }
30
+ | { type: 'doctor'; requestId: DoctorRequestId }
31
+ | { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
12
32
 
13
33
  export type QueueStateItem = { id: string; text: string; ts: number }
14
34
 
@@ -23,3 +43,5 @@ export type ServerMessage =
23
43
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
24
44
  | { type: 'queue_state'; pending: QueueStateItem[] }
25
45
  | { type: 'prompt_started'; messageId: string; text: string }
46
+ | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
47
+ | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
@@ -403,7 +403,7 @@ RUN apt-get install ... <baseline + enabled toggle packages> ← toggles fan o
403
403
  ENV NODE_ENV=production
404
404
  # Custom lines from typeclaw.json#dockerfile.append. ← only emitted when append is non-empty
405
405
  <your appended lines>
406
- ENTRYPOINT ["bun", "run", "typeclaw"]
406
+ ENTRYPOINT ["/usr/local/bin/typeclaw-entrypoint"]
407
407
  CMD ["run"]
408
408
  ```
409
409
 
@@ -706,6 +706,18 @@
706
706
  "allow"
707
707
  ]
708
708
  },
709
+ "network": {
710
+ "default": {
711
+ "blockInternal": false
712
+ },
713
+ "type": "object",
714
+ "properties": {
715
+ "blockInternal": {
716
+ "default": false,
717
+ "type": "boolean"
718
+ }
719
+ }
720
+ },
709
721
  "dockerfile": {
710
722
  "default": {
711
723
  "ffmpeg": false,
@@ -816,6 +828,44 @@
816
828
  }
817
829
  }
818
830
  }
831
+ },
832
+ "backup": {
833
+ "default": {
834
+ "enabled": true,
835
+ "idleMs": 30000,
836
+ "pushToOrigin": true,
837
+ "commitTimeoutMs": 30000,
838
+ "networkTimeoutMs": 60000
839
+ },
840
+ "type": "object",
841
+ "properties": {
842
+ "enabled": {
843
+ "default": true,
844
+ "type": "boolean"
845
+ },
846
+ "idleMs": {
847
+ "default": 30000,
848
+ "type": "integer",
849
+ "minimum": 1000,
850
+ "maximum": 9007199254740991
851
+ },
852
+ "pushToOrigin": {
853
+ "default": true,
854
+ "type": "boolean"
855
+ },
856
+ "commitTimeoutMs": {
857
+ "default": 30000,
858
+ "type": "integer",
859
+ "minimum": 1,
860
+ "maximum": 9007199254740991
861
+ },
862
+ "networkTimeoutMs": {
863
+ "default": 60000,
864
+ "type": "integer",
865
+ "minimum": 1,
866
+ "maximum": 9007199254740991
867
+ }
868
+ }
819
869
  }
820
870
  },
821
871
  "additionalProperties": {}