typeclaw 0.1.0 → 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 (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. package/typeclaw.schema.json +50 -4
@@ -104,6 +104,23 @@ export const gitignoreSchema = z
104
104
 
105
105
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
106
106
 
107
+ // `blockInternal` is the kill-switch for the container-stage egress filter
108
+ // installed by Dockerfile entrypoint shim: when true, the container is granted
109
+ // CAP_NET_ADMIN at boot just long enough to install iptables OUTPUT rules
110
+ // that DROP traffic to RFC1918, link-local (incl. cloud metadata), CGNAT,
111
+ // multicast/reserved, IPv6 ULA/link-local/multicast. The capability is then
112
+ // dropped from the bounding set via setpriv before the agent process exec's,
113
+ // so no child (python, curl, bun-spawned anything) can mutate or recover it.
114
+ // Default is `false` so existing agent folders are unaffected by an upgrade;
115
+ // `typeclaw init` writes `true` for new agents (handled separately in init).
116
+ export const networkSchema = z
117
+ .object({
118
+ blockInternal: z.boolean().default(false),
119
+ })
120
+ .default({ blockInternal: false })
121
+
122
+ export type NetworkConfig = z.infer<typeof networkSchema>
123
+
107
124
  export const configSchema = z
108
125
  .object({
109
126
  $schema: z.string().optional(),
@@ -123,6 +140,7 @@ export const configSchema = z
123
140
  alias: z.array(z.string().trim().min(1)).default([]),
124
141
  channels: channelsSchema,
125
142
  portForward: portForwardSchema,
143
+ network: networkSchema,
126
144
  dockerfile: dockerfileSchema,
127
145
  gitignore: gitignoreSchema,
128
146
  })
@@ -213,6 +231,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
213
231
  alias: 'applied',
214
232
  channels: 'applied',
215
233
  portForward: 'restart-required',
234
+ network: 'restart-required',
216
235
  dockerfile: 'restart-required',
217
236
  gitignore: 'restart-required',
218
237
  }
@@ -276,6 +295,7 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
276
295
  'plugins',
277
296
  'channels',
278
297
  'portForward',
298
+ 'network',
279
299
  'dockerfile',
280
300
  'gitignore',
281
301
  ])
@@ -0,0 +1,75 @@
1
+ // Per-source line tinting for `typeclaw logs` and `typeclaw compose logs`.
2
+ // PR #146's wall-clock timestamp prefix made every line start identically,
3
+ // dropping readability when many sources interleave. We restore visual
4
+ // grouping by tinting each line based on its first `[tag]` (`[plugin:memory]`,
5
+ // `[memory-logger]`, etc.). Same trick `compose/logs.ts#colorFor` uses for
6
+ // agent names — stable hash → palette index → ANSI escape that wraps the
7
+ // whole line body.
8
+ //
9
+ // Lines without a `[tag]` get no tint (only the leading timestamp is dimmed),
10
+ // so untagged docker output, raw stack traces, and channel adapter logs
11
+ // stay readable without inheriting a misleading color.
12
+
13
+ import type { WritableStream as NodeWritable } from 'node:stream/web'
14
+
15
+ const ANSI_RESET = '\x1b[0m'
16
+ const ANSI_DIM = '\x1b[2m'
17
+ const ANSI_CYAN = '\x1b[36m'
18
+ const ANSI_YELLOW = '\x1b[33m'
19
+ const ANSI_GREEN = '\x1b[32m'
20
+ const ANSI_MAGENTA = '\x1b[35m'
21
+ const ANSI_BLUE = '\x1b[34m'
22
+ const ANSI_BRIGHT_CYAN = '\x1b[96m'
23
+ const ANSI_BRIGHT_YELLOW = '\x1b[93m'
24
+ const ANSI_BRIGHT_GREEN = '\x1b[92m'
25
+ const ANSI_BRIGHT_MAGENTA = '\x1b[95m'
26
+ const ANSI_BRIGHT_BLUE = '\x1b[94m'
27
+
28
+ const TAG_PALETTE = [
29
+ ANSI_CYAN,
30
+ ANSI_YELLOW,
31
+ ANSI_GREEN,
32
+ ANSI_MAGENTA,
33
+ ANSI_BLUE,
34
+ ANSI_BRIGHT_CYAN,
35
+ ANSI_BRIGHT_YELLOW,
36
+ ANSI_BRIGHT_GREEN,
37
+ ANSI_BRIGHT_MAGENTA,
38
+ ANSI_BRIGHT_BLUE,
39
+ ] as const
40
+
41
+ // Anchored to line start with a trailing space so a date that happens to
42
+ // appear mid-line doesn't get dimmed.
43
+ const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?= )/
44
+
45
+ const TAG_RE = /\[([^\]\n]+)\]/
46
+
47
+ // `useColor=false` returns the input verbatim; the contract that protects
48
+ // pipes, file redirects, NO_COLOR, and tests from ANSI leakage.
49
+ export function colorize(line: string, useColor: boolean): string {
50
+ if (!useColor || line.length === 0) return line
51
+
52
+ const ts = TIMESTAMP_RE.exec(line)
53
+ const timestampLen = ts ? ts[0].length : 0
54
+ const dimmedTimestamp = ts ? `${ANSI_DIM}${ts[0]}${ANSI_RESET}` : ''
55
+ const rest = line.slice(timestampLen)
56
+
57
+ const tag = TAG_RE.exec(rest)
58
+ if (!tag) return `${dimmedTimestamp}${rest}`
59
+
60
+ const tint = paletteColor(tag[1] ?? '')
61
+ return `${dimmedTimestamp}${tint}${rest}${ANSI_RESET}`
62
+ }
63
+
64
+ function paletteColor(seed: string): string {
65
+ let h = 0
66
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
67
+ return TAG_PALETTE[h % TAG_PALETTE.length] ?? TAG_PALETTE[0]
68
+ }
69
+
70
+ export function supportsColor(stream: NodeJS.WritableStream | NodeWritable): boolean {
71
+ const tty = (stream as unknown as { isTTY?: boolean }).isTTY === true
72
+ if (!tty) return false
73
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
74
+ return true
75
+ }
@@ -0,0 +1,84 @@
1
+ // Docker emits each `--timestamps` line as `<RFC3339Nano> <body>\n`, e.g.
2
+ // `2026-05-13T14:23:01.123456789Z hello world\n`. RFC3339Nano is precise but
3
+ // painful to read live; humans want a wall-clock prefix. This module parses
4
+ // the leading token, reformats it to `YYYY-MM-DD HH:MM:SS` in the host's
5
+ // local timezone, and passes everything after the first space through.
6
+ //
7
+ // Stateful chunker mirroring src/compose/logs.ts#makeLinePrefixer: only emits
8
+ // newline-terminated lines and flushes the un-terminated tail on EOF, so
9
+ // interleaved reads from `docker logs` can never shred a line mid-character.
10
+
11
+ import { colorize } from './log-colors'
12
+
13
+ // `2026-05-13T14:23:01.123456789Z` or `...+09:00` etc. We accept anything
14
+ // from Docker that Date can parse, but anchor on the ISO date+time prefix to
15
+ // avoid eating non-timestamped log content.
16
+ const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})) (.*)$/
17
+
18
+ export type TimestampReformatter = {
19
+ write: (chunk: string) => string
20
+ flush: () => string
21
+ }
22
+
23
+ export type ReformatterOptions = {
24
+ // Color is opt-in and applied per emitted line *after* timestamp rewriting.
25
+ // Defaults to `false` so tests stay deterministic and pipes/files stay
26
+ // ANSI-free unless callers explicitly opt in via supportsColor(out).
27
+ color?: boolean
28
+ }
29
+
30
+ export function makeLogTimestampReformatter(
31
+ now: () => Date = () => new Date(),
32
+ options: ReformatterOptions = {},
33
+ ): TimestampReformatter {
34
+ const useColor = options.color ?? false
35
+ let buffer = ''
36
+ return {
37
+ write(chunk: string): string {
38
+ buffer += chunk
39
+ const nl = buffer.lastIndexOf('\n')
40
+ if (nl < 0) return ''
41
+ const complete = buffer.slice(0, nl + 1)
42
+ buffer = buffer.slice(nl + 1)
43
+ return complete
44
+ .split('\n')
45
+ .slice(0, -1)
46
+ .map((line) => `${colorize(reformatLine(line, now), useColor)}\n`)
47
+ .join('')
48
+ },
49
+ flush(): string {
50
+ if (buffer.length === 0) return ''
51
+ const out = `${colorize(reformatLine(buffer, now), useColor)}\n`
52
+ buffer = ''
53
+ return out
54
+ },
55
+ }
56
+ }
57
+
58
+ // Exported for tests. Format: `YYYY-MM-DD HH:MM:SS <rest>`. Falls back to the
59
+ // raw line if it doesn't look like a Docker `--timestamps` line, and falls
60
+ // back to `now()` if Docker's timestamp doesn't parse (defensive — shouldn't
61
+ // happen, but losing one timestamp shouldn't hide the log body).
62
+ export function reformatLine(line: string, now: () => Date = () => new Date()): string {
63
+ const match = TIMESTAMP_RE.exec(line)
64
+ if (!match) return line
65
+ const [, raw, body] = match
66
+ if (raw === undefined || body === undefined) return line
67
+ const parsed = new Date(raw)
68
+ const stamp = formatLocal(Number.isNaN(parsed.getTime()) ? now() : parsed)
69
+ return `${stamp} ${body}`
70
+ }
71
+
72
+ function formatLocal(d: Date): string {
73
+ const year = d.getFullYear()
74
+ const month = pad2(d.getMonth() + 1)
75
+ const day = pad2(d.getDate())
76
+ const hour = pad2(d.getHours())
77
+ const minute = pad2(d.getMinutes())
78
+ const second = pad2(d.getSeconds())
79
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}`
80
+ }
81
+
82
+ function pad2(n: number): string {
83
+ return n < 10 ? `0${n}` : String(n)
84
+ }
@@ -1,3 +1,5 @@
1
+ import { supportsColor } from './log-colors'
2
+ import { makeLogTimestampReformatter, type TimestampReformatter } from './log-timestamps'
1
3
  import { containerExists, containerNameFromCwd, getBun } from './shared'
2
4
 
3
5
  export type LogsPlan = {
@@ -7,7 +9,25 @@ export type LogsPlan = {
7
9
 
8
10
  export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
9
11
 
10
- export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): Promise<LogsResult> {
12
+ export type LogsOptions = {
13
+ cwd: string
14
+ follow: boolean
15
+ out?: NodeJS.WritableStream
16
+ err?: NodeJS.WritableStream
17
+ signal?: AbortSignal
18
+ // When undefined, defaults to TTY+NO_COLOR detection on `out`/`err`.
19
+ // Tests pass `false` for deterministic plain output.
20
+ useColor?: boolean
21
+ }
22
+
23
+ export async function logs({
24
+ cwd,
25
+ follow,
26
+ out = process.stdout,
27
+ err = process.stderr,
28
+ signal,
29
+ useColor,
30
+ }: LogsOptions): Promise<LogsResult> {
11
31
  const bun = getBun()
12
32
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
13
33
 
@@ -18,14 +38,30 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
18
38
  return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
19
39
  }
20
40
 
21
- const cmd = ['docker', 'logs']
41
+ const cmd = ['docker', 'logs', '--timestamps']
22
42
  if (follow) cmd.push('-f')
23
43
  cmd.push(containerName)
24
44
 
25
- // Inherit stdio so logs stream live and Ctrl+C reaches `docker logs`,
26
- // which exits cleanly on SIGINT in follow mode.
27
- const proc = bun.spawn({ cmd, cwd, stdout: 'inherit', stderr: 'inherit' })
45
+ const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
46
+
47
+ const onAbort = (): void => {
48
+ try {
49
+ proc.kill('SIGTERM')
50
+ } catch {
51
+ // already exited
52
+ }
53
+ }
54
+ signal?.addEventListener('abort', onAbort, { once: true })
55
+
56
+ const colorOut = useColor ?? supportsColor(out)
57
+ const colorErr = useColor ?? supportsColor(err)
58
+ await Promise.all([
59
+ pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
60
+ pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
61
+ ])
28
62
  const exitCode = await proc.exited
63
+ signal?.removeEventListener('abort', onAbort)
64
+
29
65
  return { ok: true, containerName, exitCode }
30
66
  } catch (error) {
31
67
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -35,3 +71,33 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
35
71
  export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
36
72
  return { containerName: containerNameFromCwd(cwd), follow }
37
73
  }
74
+
75
+ // Exported for `compose/logs.ts` so the multi-agent path reuses the same
76
+ // reformatter and stays consistent with single-agent output.
77
+ export async function pumpWithTimestamps(
78
+ stream: ReadableStream<Uint8Array>,
79
+ sink: NodeJS.WritableStream,
80
+ reformatter: TimestampReformatter = makeLogTimestampReformatter(),
81
+ ): Promise<void> {
82
+ const decoder = new TextDecoder()
83
+ const reader = stream.getReader()
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read()
87
+ if (done) break
88
+ if (value && value.byteLength > 0) {
89
+ const out = reformatter.write(decoder.decode(value, { stream: true }))
90
+ if (out.length > 0) sink.write(out)
91
+ }
92
+ }
93
+ const tail = decoder.decode()
94
+ if (tail.length > 0) {
95
+ const out = reformatter.write(tail)
96
+ if (out.length > 0) sink.write(out)
97
+ }
98
+ const flushed = reformatter.flush()
99
+ if (flushed.length > 0) sink.write(flushed)
100
+ } finally {
101
+ reader.releaseLock()
102
+ }
103
+ }
@@ -7,6 +7,7 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
7
7
  import { send as sendToDaemon } from '@/hostd/client'
8
8
  import type { HttpInfoResult } from '@/hostd/protocol'
9
9
  import { ensureDaemon } from '@/hostd/spawn'
10
+ import { resolveBaseImageVersion } from '@/init/cli-version'
10
11
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
11
12
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
12
13
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
@@ -142,7 +143,6 @@ export async function start({
142
143
  // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
143
144
  // is a no-op, so users who never edit their agent folder pay zero cost on
144
145
  // subsequent starts and users who customized `workspaces` are not clobbered.
145
- await refreshDockerfile(cwd)
146
146
  await refreshGitignore(cwd)
147
147
  const pkgRefresh = await refreshPackageJson(cwd)
148
148
  await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
@@ -161,6 +161,11 @@ export async function start({
161
161
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
162
162
  }
163
163
  await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
164
+ // Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
165
+ // line resolves against the agent's installed node_modules/typeclaw —
166
+ // ensures the base image's CLI version matches the runtime the
167
+ // container will actually load.
168
+ await refreshDockerfile(cwd)
164
169
 
165
170
  if (state.exists) {
166
171
  // Container holds the name but is not running. Without `--rm`, this is
@@ -315,7 +320,8 @@ export async function planStart({
315
320
  const imageTag = imageTagFromCwd(cwd)
316
321
 
317
322
  const devSourcePath = await detectDevSource(cwd)
318
- const mounts = await loadMounts(cwd)
323
+ const cfg = await loadTypeclawConfig(cwd)
324
+ const mounts = cfg.mounts
319
325
 
320
326
  // No `--rm`: a crashed container's logs MUST survive past exit so users can
321
327
  // debug the failure. `typeclaw stop` removes the container explicitly, and
@@ -324,6 +330,17 @@ export async function planStart({
324
330
  // a running container or one the user has not started again yet.
325
331
  const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
326
332
 
333
+ // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
334
+ // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
335
+ // install iptables OUTPUT rules. The shim drops the capability from the
336
+ // bounding set via setpriv before exec'ing the agent — see the shim source
337
+ // in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
338
+ // tells the shim to take the on-path; absent or set to anything other than
339
+ // "1", the shim is a no-op.
340
+ if (cfg.network.blockInternal) {
341
+ runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
342
+ }
343
+
327
344
  if (hostdControl) {
328
345
  runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
329
346
  }
@@ -386,7 +403,10 @@ export async function planStart({
386
403
 
387
404
  export async function refreshDockerfile(cwd: string): Promise<void> {
388
405
  const cfg = await loadTypeclawConfig(cwd)
389
- await writeFile(join(cwd, DOCKERFILE), buildDockerfile(cfg.dockerfile))
406
+ await writeFile(
407
+ join(cwd, DOCKERFILE),
408
+ buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
409
+ )
390
410
  }
391
411
 
392
412
  export async function refreshGitignore(cwd: string): Promise<void> {
@@ -576,11 +596,6 @@ async function detectDevSource(cwd: string): Promise<string | null> {
576
596
  // folder mid-init). Anything else — malformed JSON, schema-invalid config,
577
597
  // invalid mount entry — must surface so the user sees they configured a mount
578
598
  // that won't be applied.
579
- async function loadMounts(cwd: string): Promise<Config['mounts']> {
580
- const cfg = await loadTypeclawConfig(cwd)
581
- return cfg.mounts
582
- }
583
-
584
599
  async function loadTypeclawConfig(cwd: string): Promise<Config> {
585
600
  return configSchema.parse(await loadConfigJson(cwd))
586
601
  }
@@ -1,20 +1,25 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
1
2
  import type { HookBus } from '@/plugin'
2
3
  import type { Stream, Unsubscribe } from '@/stream'
3
4
 
4
5
  import type { CronJob, ExecJob, PromptJob } from './schema'
5
6
 
6
- // `hooks`, `sessionId`, and `getTranscriptPath` are optional so test fakes can
7
- // stay one-liners. When present, the consumer fires `session.idle` after every
8
- // prompt completion and `session.end` on dispose, mirroring the lifecycle
9
- // signals the TUI server already emits in `src/server/index.ts`. Without this
10
- // the bundled memory plugin's debounced `memory-logger` never spawns for cron
11
- // prompt jobs because it only wakes on `session.idle`.
7
+ // `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
8
+ // test fakes can stay one-liners. When present, the consumer fires
9
+ // `session.turn.start`/`session.turn.end` around `prompt()`, then
10
+ // `session.idle` after, then `session.end` on dispose mirroring the
11
+ // lifecycle signals the TUI server emits in `src/server/index.ts`. Without
12
+ // this the bundled memory plugin's debounced `memory-logger` never spawns for
13
+ // cron prompt jobs (it only wakes on `session.idle`), and the bundled backup
14
+ // plugin's turn counter would miss cron-driven activity.
12
15
  export type CronSession = {
13
16
  prompt: (text: string) => Promise<void>
14
17
  dispose?: () => void
15
18
  hooks?: HookBus
16
19
  sessionId?: string
20
+ agentDir?: string
17
21
  getTranscriptPath?: () => string | undefined
22
+ origin?: SessionOrigin
18
23
  }
19
24
 
20
25
  export type CronConsumerLogger = {
@@ -102,8 +107,25 @@ async function runPrompt(
102
107
  return
103
108
  }
104
109
  const session = await createSessionForCron(job)
110
+ const turnEvent =
111
+ session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
112
+ ? {
113
+ sessionId: session.sessionId,
114
+ agentDir: session.agentDir,
115
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
116
+ }
117
+ : undefined
105
118
  try {
106
- await session.prompt(job.prompt)
119
+ if (session.hooks && turnEvent !== undefined) {
120
+ await session.hooks.runSessionTurnStart(turnEvent)
121
+ }
122
+ try {
123
+ await session.prompt(job.prompt)
124
+ } finally {
125
+ if (session.hooks && turnEvent !== undefined) {
126
+ await session.hooks.runSessionTurnEnd(turnEvent)
127
+ }
128
+ }
107
129
  if (session.hooks && session.sessionId !== undefined) {
108
130
  await session.hooks.runSessionIdle({
109
131
  sessionId: session.sessionId,