typeclaw 0.36.2 → 0.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.36.2",
3
+ "version": "0.36.4",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -48,7 +48,7 @@
48
48
  "@mariozechner/pi-tui": "^0.67.3",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "agent-messenger": "2.19.2",
51
+ "agent-messenger": "2.19.4",
52
52
  "cheerio": "^1.2.0",
53
53
  "citty": "^0.2.2",
54
54
  "cron-parser": "^5.5.0",
@@ -53,7 +53,7 @@ import {
53
53
  resolveSandboxSymlinks,
54
54
  resolveWritableZones,
55
55
  SandboxDegradedProcError,
56
- type SandboxProcStrategy,
56
+ SandboxProcProbeUnverifiedError,
57
57
  subtractMasked,
58
58
  } from '@/sandbox'
59
59
 
@@ -644,15 +644,19 @@ async function applyBashSandbox(
644
644
  // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
645
645
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
646
646
  // path does not run when the command is rewritten to a bwrap invocation).
647
- const proc = await resolveProcStrategy()
647
+ const { strategy: proc, degradeReason } = await resolveProcStrategy()
648
648
  // Fail fast with an actionable error when /proc degraded to tmpfs AND the
649
649
  // command needs a real /proc: under tmpfs Bun would otherwise abort deep in its
650
- // pipeline with the opaque "NotDir", which the model retries forever. The
651
- // SandboxDegradedProcError message tells it this is an environment limit, not
652
- // the command's fault. Guarded on the command so non-bun bash still runs in the
653
- // degraded mode (it does not touch /proc/self/{fd,maps}).
650
+ // pipeline with the opaque "NotDir", which the model retries forever. Which
651
+ // error depends on WHY it degraded: a 'definitive' degrade (a real leak / an
652
+ // incapable host) is permanent SandboxDegradedProcError ("retrying won't
653
+ // help"); an 'unverified' degrade (the safety probe stayed inconclusive through
654
+ // its retry budget, e.g. a boot-time load spike) is transient and re-probes on
655
+ // the next call → SandboxProcProbeUnverifiedError ("retry the same command").
656
+ // Guarded on the command so non-bun bash still runs in the degraded mode (it
657
+ // does not touch /proc/self/{fd,maps}).
654
658
  if (proc === 'tmpfs' && commandNeedsRealProc(command)) {
655
- throw new SandboxDegradedProcError()
659
+ throw degradeReason === 'unverified' ? new SandboxProcProbeUnverifiedError() : new SandboxDegradedProcError()
656
660
  }
657
661
  const { commandString } = buildSandboxedCommand(command, {
658
662
  mounts: [
@@ -698,26 +702,44 @@ function subtractMaskedProtected(
698
702
  // --mount-proc` in a container booted WITHOUT the cap (or vice versa). Both
699
703
  // probes are cached process-globally, so this resolves to one spawn per
700
704
  // container lifetime regardless of how many bash calls hit it.
701
- async function resolveProcStrategy(): Promise<SandboxProcStrategy> {
702
- if (config.sandbox.realProc && (await canMountRealProc())) return 'real-proc'
705
+ // A tmpfs degrade carries WHY it happened so the caller can pick a permanent vs
706
+ // retryable error. 'definitive': the probe returned a real cross-userns leak
707
+ // ('unsafe') — the ONLY verdict proven permanent, so it fails closed for good.
708
+ // 'unverified': the safety probe never reached a definitive verdict within its
709
+ // retry budget. That covers BOTH a transient load spike AND a durable
710
+ // incapability (no usable namespaces, a bwrap that starts but cannot set up its
711
+ // sandbox): the probe cannot prove a NEGATIVE capability — only a leak is
712
+ // definitive — so a genuinely incapable host also lands here and simply keeps
713
+ // re-degrading on each call. Since 'inconclusive' is never cached, that costs a
714
+ // re-probe but is correct: the only false case is "capable but briefly
715
+ // saturated", which recovers; an incapable host stays degraded either way.
716
+ // Absent when the strategy is not tmpfs.
717
+ type ProcStrategyResolution =
718
+ | { strategy: 'real-proc' | 'proc-bind'; degradeReason?: undefined }
719
+ | { strategy: 'tmpfs'; degradeReason: 'definitive' | 'unverified' }
720
+
721
+ async function resolveProcStrategy(): Promise<ProcStrategyResolution> {
722
+ if (config.sandbox.realProc && (await canMountRealProc())) return { strategy: 'real-proc' }
703
723
  // Retry an 'inconclusive' proc-bind probe (transient under load) before
704
724
  // degrading — a single such hiccup must not break external-package runs on a
705
725
  // capable host. 'unsafe' still fails closed with no retry.
706
- if (
707
- await resolveProcBindSafetyWithRetry(
708
- () => getProcBindSafetyVerdict(),
709
- (ms) => Bun.sleep(ms),
710
- )
726
+ const verdict = await resolveProcBindSafetyWithRetry(
727
+ () => getProcBindSafetyVerdict(),
728
+ (ms) => Bun.sleep(ms),
711
729
  )
712
- return 'proc-bind'
730
+ if (verdict === 'safe') return { strategy: 'proc-bind' }
713
731
  // Degraded last resort: no working /proc strategy. External package runners
714
732
  // (bunx/bun add/bun run <pkg-bin>) will fail with Bun's opaque "NotDir" because
715
- // /proc/self/{fd,maps} are absent. Warn once so an operator on such an exotic
716
- // host (no usable user namespaces at all) gets a diagnostic instead of the bare
717
- // Bun error. Not gated on parsing the command that heuristic is fragile (see
718
- // PR #696); this is a strategy-level notice, fail-closed and command-agnostic.
719
- warnTmpfsProcFallbackOnce()
720
- return 'tmpfs'
733
+ // /proc/self/{fd,maps} are absent. Only a proven 'unsafe' (a real cross-userns
734
+ // leak) is DEFINITIVE warn once (a real operator-facing limit). An
735
+ // 'inconclusive' is reported as retryable upstream and NOT warned (it would cry
736
+ // wolf every boot storm); a durably-incapable host re-degrades quietly here,
737
+ // since the probe cannot distinguish it from transient load.
738
+ if (verdict === 'unsafe') {
739
+ warnTmpfsProcFallbackOnce()
740
+ return { strategy: 'tmpfs', degradeReason: 'definitive' }
741
+ }
742
+ return { strategy: 'tmpfs', degradeReason: 'unverified' }
721
743
  }
722
744
 
723
745
  let tmpfsProcFallbackWarned = false
@@ -1,5 +1,8 @@
1
1
  import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
2
2
 
3
+ const PACKAGE_JSON_INSTALL_RULE =
4
+ "After editing `package.json` (adding, removing, or bumping dependencies/plugins), run the project's package manager to update the lockfile and installed dependency state — e.g. `bun install`, `npm install`, `pnpm install`, or `yarn install`, matching the existing lockfile. Commit the lockfile change alongside the `package.json` edit."
5
+
3
6
  // The orchestration roster (the `Briefly: ...` enumeration of public subagents)
4
7
  // is GENERATED from the registry by `renderPublicSubagentRoster` and threaded in
5
8
  // here, so a newly-registered public subagent can never be silently missing from
@@ -83,6 +86,7 @@ Your agent folder is a git repository.
83
86
  - Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
84
87
  - Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
85
88
  - Never commit \`secrets.json\`, \`.env\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
89
+ - ${PACKAGE_JSON_INSTALL_RULE}
86
90
  - Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
87
91
 
88
92
  ## How to behave
@@ -251,6 +255,8 @@ Never suppress errors to make things "work", and never fabricate results. If som
251
255
 
252
256
  Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
253
257
 
258
+ ${PACKAGE_JSON_INSTALL_RULE}
259
+
254
260
  Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. \`public/\` is the guest-visible zone — write there anything meant to be shared with an untrusted caller (a \`guest\`-role turn cannot read \`workspace/\` but can read \`public/\`). Do not edit \`memory/topics/\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`. Never stage or commit \`secrets.json\`, \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
255
261
 
256
262
  See the session-origin block below for what kind of session this is and what's expected of you.`
@@ -0,0 +1,97 @@
1
+ import type { LinePushMessageEvent } from 'agent-messenger/line'
2
+
3
+ import type { InboundAttachment } from '@/channels/types'
4
+
5
+ // Splits an inbound LINE event into (text, attachments[]). Text is what the
6
+ // agent sees in its prompt; attachments[] carries the in-turn id + kind the
7
+ // router uses to resolve `channel_fetch_attachment` / `look_at` by id.
8
+ //
9
+ // LINE differs from KakaoTalk in one load-bearing way: the upstream SDK
10
+ // (`agent-messenger/line`) currently forwards only `content_type` on the push
11
+ // event, NOT `contentMetadata`. So unlike the KakaoTalk splitter, this one has
12
+ // no sticker id / file name / media URL to surface — every attachment is
13
+ // REF-FREE (empty `ref`, no fetchable handle). The placeholder is therefore
14
+ // coarse on purpose (`[LINE sticker]`, `[LINE image]`). When the SDK starts
15
+ // forwarding metadata (agent-messenger#214), enrich this file only; the
16
+ // adapter / classifier contract does not change.
17
+ //
18
+ // Keeping the ref out of the prompt text is the same invariant the KakaoTalk
19
+ // splitter documents: there is exactly ONE way to fetch an attachment — by its
20
+ // in-turn id — so a hallucinated/malformed ref can never reach a tool.
21
+
22
+ export type SplitInboundLine = {
23
+ text: string
24
+ attachments: InboundAttachment[]
25
+ }
26
+
27
+ // LINE thrift ContentType. The SDK stringifies `msg.raw.contentType`, which the
28
+ // thrift layer usually renders as the symbolic name, but the wire enum is
29
+ // numeric (see @evex/linejs-types ContentType). Normalize defends against both
30
+ // forms so a numeric leak ("7") still maps to STICKER rather than falling
31
+ // through to the unknown bucket.
32
+ const NUMERIC_CONTENT_TYPE: Record<string, string> = {
33
+ '0': 'NONE',
34
+ '1': 'IMAGE',
35
+ '2': 'VIDEO',
36
+ '3': 'AUDIO',
37
+ '7': 'STICKER',
38
+ '13': 'CONTACT',
39
+ '14': 'FILE',
40
+ '15': 'LOCATION',
41
+ }
42
+
43
+ // Non-text content types that map cleanly onto the fixed InboundAttachment.kind
44
+ // union. Types with no clean mapping (CONTACT, LOCATION, and anything unknown)
45
+ // route as placeholder-only text — an attachment with an empty ref and an
46
+ // invented kind would offer the agent an unusable handle, so we don't make one.
47
+ const CONTENT_TYPE_TO_KIND: Record<string, InboundAttachment['kind']> = {
48
+ STICKER: 'sticker',
49
+ IMAGE: 'photo',
50
+ VIDEO: 'video',
51
+ AUDIO: 'audio',
52
+ FILE: 'file',
53
+ }
54
+
55
+ const PLACEHOLDER_ONLY_LABEL: Record<string, string> = {
56
+ CONTACT: 'contact',
57
+ LOCATION: 'location',
58
+ }
59
+
60
+ export function normalizeLineContentType(raw: string | null | undefined): string {
61
+ if (raw === null || raw === undefined) return 'NONE'
62
+ const trimmed = raw.trim()
63
+ if (trimmed === '') return 'NONE'
64
+ const numeric = NUMERIC_CONTENT_TYPE[trimmed]
65
+ if (numeric !== undefined) return numeric
66
+ const upper = trimmed.toUpperCase()
67
+ // LINE text is `NONE` on the wire; treat the `TEXT` spelling as the same so
68
+ // a genuine text message never falls into the placeholder path.
69
+ return upper === 'TEXT' ? 'NONE' : upper
70
+ }
71
+
72
+ export function splitInboundLine(event: LinePushMessageEvent, startId = 1): SplitInboundLine {
73
+ const contentType = normalizeLineContentType(event.content_type)
74
+
75
+ // NONE is LINE text; a blank NONE message stays an `empty_text` drop in the
76
+ // classifier, so synthesize nothing and pass the raw text through.
77
+ if (contentType === 'NONE') {
78
+ return { text: event.text ?? '', attachments: [] }
79
+ }
80
+
81
+ const kind = CONTENT_TYPE_TO_KIND[contentType]
82
+ const rawText = event.text ?? ''
83
+
84
+ if (kind !== undefined) {
85
+ const id = startId
86
+ const placeholder = `[LINE ${kind}]`
87
+ const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
88
+ return { text, attachments: [{ id, kind, ref: '' }] }
89
+ }
90
+
91
+ // Placeholder-only types (contact, location, unknown/future). No attachment
92
+ // entry — there is nothing fetchable and no valid kind to assign.
93
+ const label = PLACEHOLDER_ONLY_LABEL[contentType] ?? `message: ${contentType}`
94
+ const placeholder = `[LINE ${label}]`
95
+ const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
96
+ return { text, attachments: [] }
97
+ }
@@ -2,7 +2,7 @@ import type { LinePushMessageEvent } from 'agent-messenger/line'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
- import type { InboundMessage } from '@/channels/types'
5
+ import type { InboundAttachment, InboundMessage } from '@/channels/types'
6
6
 
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
8
8
 
@@ -22,6 +22,13 @@ export type LineInboundContext = {
22
22
  // LINE push events lack `author_name`, so the adapter resolves it (best
23
23
  // effort) and passes it here; falls back to the raw author id.
24
24
  authorName?: string
25
+ // The adapter splits the raw event into prompt text + attachments (non-text
26
+ // content types become a placeholder string and a ref-free attachment) and
27
+ // passes the result here, so the classifier routes on the synthesized text
28
+ // rather than the raw `event.text`. Omitted for plain text inbounds, where
29
+ // `event.text` is authoritative.
30
+ text?: string
31
+ attachments?: readonly InboundAttachment[]
25
32
  }
26
33
 
27
34
  export function classifyInbound(
@@ -36,8 +43,11 @@ export function classifyInbound(
36
43
  return { kind: 'drop', reason: 'self_author' }
37
44
  }
38
45
 
39
- const text = event.text ?? ''
40
- if (text === '') return { kind: 'drop', reason: 'empty_text' }
46
+ const text = context.text ?? event.text ?? ''
47
+ const attachments = context.attachments ?? []
48
+ if (text === '' && attachments.length === 0) {
49
+ return { kind: 'drop', reason: 'empty_text' }
50
+ }
41
51
 
42
52
  const chatInfo = context.lookupChat(event.chat_id)
43
53
  if (chatInfo === null) {
@@ -65,6 +75,7 @@ export function classifyInbound(
65
75
  chat: event.chat_id,
66
76
  thread: null,
67
77
  text,
78
+ ...(attachments.length > 0 ? { attachments } : {}),
68
79
  externalMessageId: event.message_id,
69
80
  authorId: event.author_id,
70
81
  authorName,
@@ -25,6 +25,7 @@ import type {
25
25
  SendResult,
26
26
  } from '@/channels/types'
27
27
 
28
+ import { splitInboundLine } from './line-attachment'
28
29
  import { createLineChannelResolver } from './line-channel-resolver'
29
30
  import { classifyInbound } from './line-classify'
30
31
  import { toLinePlainText } from './line-format'
@@ -217,13 +218,16 @@ export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
217
218
 
218
219
  const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? '@line-group'
219
220
  const inboundTag = await formatChannelTag(bucket, event.chat_id)
221
+ const { text, attachments } = splitInboundLine(event)
220
222
  logger.info(
221
- `[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} text_len=${(event.text ?? '').length}`,
223
+ `[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} content_type=${event.content_type} text_len=${text.length} attachments=${attachments.length}`,
222
224
  )
223
225
 
224
226
  const verdict = classifyInbound(event, options.configRef(), {
225
227
  selfUserId,
226
228
  lookupChat: (id) => channelResolver.lookupChat(id),
229
+ text,
230
+ attachments,
227
231
  ...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
228
232
  })
229
233
  if (verdict.kind === 'drop') {
@@ -14,6 +14,7 @@ export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent
14
14
  export type InboundDropReason =
15
15
  | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
16
16
  | 'no_user' // event has no `user` field (e.g. system messages: channel_join, message_changed)
17
+ | 'slack_system_message' // non-replyable Slack message subtype events (e.g. channel_topic)
17
18
  | 'empty_text' // event has neither text nor files — nothing for the agent to act on
18
19
  | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
19
20
 
@@ -62,6 +63,10 @@ export function classifyInbound(
62
63
  return { kind: 'drop', reason: 'no_user' }
63
64
  }
64
65
 
66
+ if (!isRouteableSlackMessageSubtype(event.subtype)) {
67
+ return { kind: 'drop', reason: 'slack_system_message' }
68
+ }
69
+
65
70
  const rawText = event.text ?? ''
66
71
  const { text, attachments } = splitInbound(event)
67
72
  const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
@@ -156,6 +161,10 @@ export function classifyInbound(
156
161
  }
157
162
  }
158
163
 
164
+ export function isRouteableSlackMessageSubtype(subtype: string | undefined): boolean {
165
+ return subtype === undefined || subtype === 'bot_message' || subtype === 'file_share' || subtype === 'me_message'
166
+ }
167
+
159
168
  // Slack encodes user mentions inline as `<@U…>` (or `<@W…>` for some org
160
169
  // accounts, and `<@U…|fallback>` when the client supplied a label). Pull
161
170
  // every distinct id out of the text — duplicates collapse so the caller
@@ -38,6 +38,7 @@ import {
38
38
  classifyInbound,
39
39
  describeSlackFile,
40
40
  type InboundDropReason,
41
+ isRouteableSlackMessageSubtype,
41
42
  renderPlaceholder,
42
43
  type SlackInboundAppMentionEvent,
43
44
  type SlackInboundMessageEvent,
@@ -694,7 +695,7 @@ export function createSlackHistoryCallback(deps: {
694
695
  }
695
696
 
696
697
  const botUserId = botUserIdRef()
697
- const rawMessages = raw.messages ?? []
698
+ const rawMessages = (raw.messages ?? []).filter((message) => isRouteableSlackMessageSubtype(message.subtype))
698
699
  const mapped = rawMessages.map((m) => mapSlackMessage(m, botUserId))
699
700
  // History payloads carry no profile, so mapSlackMessage echoes the raw
700
701
  // id into authorName; resolve it here so prompts show display names.
@@ -1316,6 +1317,7 @@ function dropHint(reason: InboundDropReason): string {
1316
1317
  case 'no_user':
1317
1318
  case 'pre_connect':
1318
1319
  case 'self_author':
1320
+ case 'slack_system_message':
1319
1321
  return ''
1320
1322
  }
1321
1323
  }
package/src/cli/reload.ts CHANGED
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { requestReload, type ReloadResult } from '@/reload'
5
+ import { requestReloadWithFallback, type ReloadResult } from '@/reload'
6
6
 
7
7
  import { c, errorLine, spinner } from './ui'
8
8
 
@@ -24,18 +24,29 @@ export const reload = defineCommand({
24
24
  },
25
25
  },
26
26
  async run({ args }) {
27
- const url = args.url ?? (await defaultUrl())
27
+ const timeoutMs = Number(args.timeout)
28
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
29
+ console.error(errorLine(`invalid --timeout value: ${args.timeout}`))
30
+ process.exit(1)
31
+ }
32
+
33
+ const target = args.url === undefined ? await defaultTarget() : { url: args.url }
28
34
 
29
35
  const s = spinner()
30
36
  s.start('Reloading...')
31
37
  let results: ReloadResult[]
38
+ let recoveredHostError: string | undefined
32
39
  try {
33
- results = await requestReload({ url, timeoutMs: Number(args.timeout) })
40
+ const response = await requestReloadWithFallback({ ...target, timeoutMs })
41
+ results = response.results
42
+ if (response.transport === 'container-local') recoveredHostError = response.hostError
34
43
  } catch (err) {
35
44
  s.error(`reload failed: ${err instanceof Error ? err.message : String(err)}`)
36
45
  process.exit(1)
37
46
  }
38
47
 
48
+ printReloadRecoveryHint(recoveredHostError)
49
+
39
50
  if (results.length === 0) {
40
51
  s.stop(c.dim('Nothing to reload.'))
41
52
  return
@@ -61,7 +72,17 @@ export const reload = defineCommand({
61
72
  },
62
73
  })
63
74
 
64
- async function defaultUrl(): Promise<string> {
75
+ export function printReloadRecoveryHint(recoveredHostError: string | undefined): void {
76
+ if (recoveredHostError === undefined) return
77
+ console.error(
78
+ c.yellow(
79
+ `Recovered via container-local reload because Docker's published host port is not accepting WebSockets (${recoveredHostError}).`,
80
+ ),
81
+ )
82
+ console.error(c.dim('Run `typeclaw restart --port 0` when safe to repair host TUI/reload connectivity.'))
83
+ }
84
+
85
+ async function defaultTarget(): Promise<{ url: string; cwd: string; token: string | null }> {
65
86
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
66
87
  const precheck = await requireContainerRunning({ cwd })
67
88
  if (!precheck.ok) {
@@ -72,5 +93,5 @@ async function defaultUrl(): Promise<string> {
72
93
  const token = await resolveTuiToken({ cwd })
73
94
  const url = new URL(`ws://127.0.0.1:${port}`)
74
95
  if (token !== null) url.searchParams.set('token', token)
75
- return url.toString()
96
+ return { url: url.toString(), cwd, token }
76
97
  }
@@ -15,6 +15,7 @@ export {
15
15
  DOCKER_NOT_FOUND_STDERR,
16
16
  imageTagFromCwd,
17
17
  inspectContainer,
18
+ sanitizeDockerStderr,
18
19
  type ContainerState,
19
20
  type DockerAvailability,
20
21
  type DockerExec,
@@ -10,6 +10,13 @@ export type RequestReloadOptions = {
10
10
 
11
11
  const DEFAULT_TIMEOUT_MS = 30_000
12
12
 
13
+ export class ReloadConnectionError extends Error {
14
+ constructor(message: string) {
15
+ super(message)
16
+ this.name = 'ReloadConnectionError'
17
+ }
18
+ }
19
+
13
20
  export async function requestReload({
14
21
  url,
15
22
  scope,
@@ -26,11 +33,15 @@ export async function requestReload({
26
33
  }
27
34
  const onError = (err: unknown) => {
28
35
  cleanup()
29
- reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
36
+ reject(
37
+ new ReloadConnectionError(
38
+ `failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`,
39
+ ),
40
+ )
30
41
  }
31
42
  const onClose = () => {
32
43
  cleanup()
33
- reject(new Error(`connection to ${displayUrl} closed before opening`))
44
+ reject(new ReloadConnectionError(`connection to ${displayUrl} closed before opening`))
34
45
  }
35
46
  const cleanup = () => {
36
47
  if (timer !== undefined) clearTimeout(timer)
@@ -41,7 +52,7 @@ export async function requestReload({
41
52
  timer = setTimeout(() => {
42
53
  cleanup()
43
54
  ws.close()
44
- reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
55
+ reject(new ReloadConnectionError(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
45
56
  }, timeoutMs)
46
57
  ws.addEventListener('open', onOpen, { once: true })
47
58
  ws.addEventListener('error', onError, { once: true })
@@ -0,0 +1,109 @@
1
+ import {
2
+ CONTAINER_PORT,
3
+ containerNameFromCwd,
4
+ defaultDockerExec,
5
+ sanitizeDockerStderr,
6
+ type DockerExec,
7
+ type DockerExecResult,
8
+ } from '@/container'
9
+
10
+ import type { ReloadResult } from './types'
11
+
12
+ export type RequestReloadViaDockerExecOptions = {
13
+ cwd: string
14
+ token: string | null
15
+ scope?: string
16
+ timeoutMs?: number
17
+ exec?: DockerExec
18
+ }
19
+
20
+ type DockerExecReloadEnvelope = { ok: true; results: ReloadResult[] } | { ok: false; reason: string }
21
+
22
+ const DEFAULT_TIMEOUT_MS = 30_000
23
+
24
+ const RELOAD_SCRIPT = String.raw`
25
+ const timeoutMs = Number(process.env.TYPECLAW_RELOAD_TIMEOUT_MS ?? '30000')
26
+ const url = new URL('ws://127.0.0.1:' + (process.env.TYPECLAW_CONTAINER_PORT ?? '8973'))
27
+ if (process.env.TYPECLAW_TUI_TOKEN) url.searchParams.set('token', process.env.TYPECLAW_TUI_TOKEN)
28
+ const ws = new WebSocket(url.toString())
29
+ let settled = false
30
+ const finish = (payload, code) => {
31
+ if (settled) return
32
+ settled = true
33
+ console.log(JSON.stringify(payload))
34
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close()
35
+ setTimeout(() => process.exit(code), 0)
36
+ }
37
+ const timer = setTimeout(() => finish({ ok: false, reason: 'timed out waiting for container-local reload_result after ' + timeoutMs + 'ms' }, 1), timeoutMs)
38
+ ws.addEventListener('open', () => {
39
+ const scope = process.env.TYPECLAW_RELOAD_SCOPE
40
+ ws.send(JSON.stringify(scope ? { type: 'reload', scope } : { type: 'reload' }))
41
+ })
42
+ ws.addEventListener('message', (event) => {
43
+ const msg = JSON.parse(String(event.data))
44
+ if (msg.type !== 'reload_result') return
45
+ clearTimeout(timer)
46
+ finish({ ok: true, results: msg.results }, 0)
47
+ })
48
+ ws.addEventListener('error', (event) => finish({ ok: false, reason: String(event.message ?? event) }, 1))
49
+ ws.addEventListener('close', () => finish({ ok: false, reason: 'container-local websocket closed before reload_result' }, 1))
50
+ `
51
+
52
+ export async function requestReloadViaDockerExec({
53
+ cwd,
54
+ token,
55
+ scope,
56
+ timeoutMs = DEFAULT_TIMEOUT_MS,
57
+ exec = defaultDockerExec,
58
+ }: RequestReloadViaDockerExecOptions): Promise<ReloadResult[]> {
59
+ const envArgs = ['-e', `TYPECLAW_CONTAINER_PORT=${CONTAINER_PORT}`, '-e', `TYPECLAW_RELOAD_TIMEOUT_MS=${timeoutMs}`]
60
+ if (token !== null) envArgs.push('-e', `TYPECLAW_TUI_TOKEN=${token}`)
61
+ if (scope !== undefined) envArgs.push('-e', `TYPECLAW_RELOAD_SCOPE=${scope}`)
62
+
63
+ const signal = AbortSignal.timeout(timeoutMs)
64
+ let result: DockerExecResult
65
+ try {
66
+ result = await exec(['exec', ...envArgs, containerNameFromCwd(cwd), 'bun', '-e', RELOAD_SCRIPT], { signal })
67
+ } catch (err) {
68
+ if (signal.aborted) throw new Error(`docker exec timed out after ${timeoutMs}ms`)
69
+ throw err
70
+ }
71
+ if (signal.aborted) throw new Error(`docker exec timed out after ${timeoutMs}ms`)
72
+ if (result.exitCode !== 0) {
73
+ const envelope = parseEnvelope(result.stdout)
74
+ if (envelope !== null && !envelope.ok) throw new Error(envelope.reason)
75
+ const reason =
76
+ sanitizeDockerStderr(result.stderr) || result.stdout.trim() || `docker exec exited with code ${result.exitCode}`
77
+ throw new Error(reason)
78
+ }
79
+
80
+ const envelope = parseEnvelope(result.stdout)
81
+ if (envelope === null) throw new Error('container-local reload returned invalid JSON')
82
+ if (!envelope.ok) throw new Error(envelope.reason)
83
+ return envelope.results
84
+ }
85
+
86
+ function parseEnvelope(stdout: string): DockerExecReloadEnvelope | null {
87
+ const line = stdout
88
+ .split('\n')
89
+ .map((entry) => entry.trim())
90
+ .filter((entry) => entry.length > 0)
91
+ .at(-1)
92
+ if (line === undefined) return null
93
+ try {
94
+ const parsed: unknown = JSON.parse(line)
95
+ return isEnvelope(parsed) ? parsed : null
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function isEnvelope(value: unknown): value is DockerExecReloadEnvelope {
102
+ if (!isRecord(value) || typeof value.ok !== 'boolean') return false
103
+ if (value.ok) return Array.isArray(value.results)
104
+ return typeof value.reason === 'string'
105
+ }
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === 'object' && value !== null
109
+ }
@@ -1,4 +1,10 @@
1
- export { requestReload, type RequestReloadOptions } from './client'
1
+ export { ReloadConnectionError, requestReload, type RequestReloadOptions } from './client'
2
+ export { requestReloadViaDockerExec, type RequestReloadViaDockerExecOptions } from './docker-exec-client'
2
3
  export { formatChannelReloadSummary } from './format'
3
4
  export { ReloadRegistry } from './registry'
5
+ export {
6
+ requestReloadWithFallback,
7
+ type RequestReloadWithFallbackOptions,
8
+ type RequestReloadWithFallbackResult,
9
+ } from './recover'
4
10
  export type { Reloadable, ReloadAllResult, ReloadResult } from './types'
@@ -0,0 +1,38 @@
1
+ import { ReloadConnectionError, requestReload } from './client'
2
+ import { requestReloadViaDockerExec } from './docker-exec-client'
3
+ import type { ReloadResult } from './types'
4
+
5
+ export type RequestReloadWithFallbackOptions = {
6
+ url: string
7
+ cwd?: string
8
+ token?: string | null
9
+ scope?: string
10
+ timeoutMs?: number
11
+ reload?: typeof requestReload
12
+ reloadViaDockerExec?: typeof requestReloadViaDockerExec
13
+ }
14
+
15
+ export type RequestReloadWithFallbackResult =
16
+ | { transport: 'host'; results: ReloadResult[] }
17
+ | { transport: 'container-local'; results: ReloadResult[]; hostError: string }
18
+
19
+ export async function requestReloadWithFallback({
20
+ url,
21
+ cwd,
22
+ token,
23
+ scope,
24
+ timeoutMs,
25
+ reload = requestReload,
26
+ reloadViaDockerExec = requestReloadViaDockerExec,
27
+ }: RequestReloadWithFallbackOptions): Promise<RequestReloadWithFallbackResult> {
28
+ try {
29
+ return { transport: 'host', results: await reload({ url, scope, timeoutMs }) }
30
+ } catch (err) {
31
+ if (!(err instanceof ReloadConnectionError) || cwd === undefined || token === undefined) throw err
32
+ return {
33
+ transport: 'container-local',
34
+ results: await reloadViaDockerExec({ cwd, token, scope, timeoutMs }),
35
+ hostError: err.message,
36
+ }
37
+ }
38
+ }
@@ -220,29 +220,46 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
220
220
  // leak-block guarantee — it only buys more chances to PROVE it.
221
221
  export const PROC_BIND_RETRY_BACKOFF_MS = [250, 1_000, 2_000, 4_000] as const
222
222
 
223
+ // The retrying resolver returns the SAME three states as the probe, never a
224
+ // boolean: 'safe' selects proc-bind; the two failure states stay DELIBERATELY
225
+ // distinct so the caller reacts differently. 'unsafe' is a DEFINITIVE host fact
226
+ // (a real cross-userns environ leak was observed, or the binary is genuinely
227
+ // absent) — permanent, fail closed, retrying buys nothing. 'inconclusive' means
228
+ // the safety probe never returned a definitive verdict within the backoff budget
229
+ // (a boot-time CPU/IO storm tripping the probe's own timeout) — it proves NOTHING
230
+ // about the host, so the SAME container can recover on a later call once the
231
+ // spike passes. Folding these two into a single boolean `false` is what made a
232
+ // transient boot-storm degrade look permanent: the caller degraded to tmpfs AND
233
+ // told the model "retrying won't help", so a capable host stayed broken until
234
+ // restart.
235
+ //
223
236
  // proc-bind selection must distinguish "definitely unavailable" from "couldn't
224
- // verify right now". A DEFINITIVE verdict is final: 'safe'→true; a real userns
225
- // leak ('unsafe')→false with NO retry. Only an 'inconclusive' verdict (transient
226
- // probe failure that proves nothing about the host) is retried, because degrading
227
- // the bash call to tmpfs over a transient hiccup is what silently broke
237
+ // verify right now". A DEFINITIVE verdict is final: 'safe'; a real userns leak
238
+ // ('unsafe') with NO retry. Only an 'inconclusive' verdict (transient probe
239
+ // failure that proves nothing about the host) is retried, because degrading the
240
+ // bash call to tmpfs over a transient hiccup is what silently broke
228
241
  // external-package runs on capable hosts. 'inconclusive' is never cached
229
242
  // (see the cache type), so each retry re-probes from scratch. After the backoff
230
- // budget is exhausted we fail CLOSED — an unverified leak-block is never treated
231
- // as safe. Pure and dependency-injected (probe + sleep) so the retry policy is
232
- // unit-testable without spawning processes; production passes
233
- // getProcBindSafetyVerdict and Bun.sleep.
243
+ // budget is exhausted we return 'inconclusive' — an unverified leak-block is
244
+ // never treated as safe, but the RESULT (a transient unknown, not a definitive
245
+ // 'unsafe') lets the caller offer a retryable degrade. Pure and
246
+ // dependency-injected (probe + sleep) so the retry policy is unit-testable
247
+ // without spawning processes; production passes getProcBindSafetyVerdict and
248
+ // Bun.sleep.
234
249
  export async function resolveProcBindSafetyWithRetry(
235
250
  probe: () => Promise<ProcBindSafetyVerdict>,
236
251
  sleep: (ms: number) => Promise<void>,
237
252
  backoffMs: readonly number[] = PROC_BIND_RETRY_BACKOFF_MS,
238
- ): Promise<boolean> {
253
+ ): Promise<ProcBindSafetyVerdict> {
239
254
  for (let attempt = 0; ; attempt++) {
240
255
  const verdict = await probe()
241
- if (verdict === 'safe') return true
242
- if (verdict === 'unsafe') return false
256
+ if (verdict === 'safe') return 'safe'
257
+ if (verdict === 'unsafe') return 'unsafe'
243
258
 
244
259
  const backoff = backoffMs[attempt]
245
- if (backoff === undefined) return false
260
+ // Budget exhausted: still unverified. Report 'inconclusive' (NOT 'unsafe') so
261
+ // the caller knows this is a retryable unknown, not a definitive host fact.
262
+ if (backoff === undefined) return 'inconclusive'
246
263
  await sleep(backoff)
247
264
  }
248
265
  }
@@ -282,9 +299,14 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
282
299
  // marker: that proves the sentinel is dumpable, same-uid, AND that this pid is
283
300
  // OUR sentinel (not a reused pid), so the ONLY thing that can deny the read
284
301
  // from inside the sandbox is the child-userns boundary (rules out a false
285
- // "blocked" from dumpable=0 / uid mismatch). If the parent can't read the
286
- // marker, the sentinel setup is unsound inconclusive, fail closed, no cache.
287
- if (!(await parentReadsSentinelMarker(sentinelPid))) return INCONCLUSIVE
302
+ // "blocked" from dumpable=0 / uid mismatch). The marker can be absent for a
303
+ // moment right after Bun.spawn: the child pid exists before `/usr/bin/env -i
304
+ // SECRET=... /bin/sleep` has exec'd and replaced its environ. Treating that
305
+ // startup race as immediate INCONCLUSIVE made the retry budget collapse into
306
+ // pure backoff time (~7.25s) and produced the first-tool `bunx` degrade even
307
+ // though the same host proved safe on the next call. Wait briefly for the
308
+ // marker before deciding setup is unsound; a real failure still fails closed.
309
+ if (!(await waitForSentinelMarker(sentinelPid))) return INCONCLUSIVE
288
310
 
289
311
  const proc = Bun.spawn(
290
312
  [
@@ -387,6 +409,9 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
387
409
  // briefly-saturated box; a genuinely wedged runtime still trips it and degrades.
388
410
  const PROC_BIND_PROBE_TIMEOUT_MS = 12_000
389
411
 
412
+ const PROC_BIND_SENTINEL_READY_TIMEOUT_MS = 1_000
413
+ const PROC_BIND_SENTINEL_READY_POLL_MS = 25
414
+
390
415
  // Designated probe-script exit codes. ONLY these two are a cacheable verdict;
391
416
  // every other code (a setup failure, bwrap startup failure, a signal, 127, …) is
392
417
  // inconclusive and must NOT be cached — see the exit-code interpretation in
@@ -457,6 +482,24 @@ async function parentReadsSentinelMarker(sentinelPid: number): Promise<boolean>
457
482
  }
458
483
  }
459
484
 
485
+ async function waitForSentinelMarker(
486
+ sentinelPid: number,
487
+ readMarker: (pid: number) => Promise<boolean> = parentReadsSentinelMarker,
488
+ sleep: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
489
+ timeoutMs: number = PROC_BIND_SENTINEL_READY_TIMEOUT_MS,
490
+ pollMs: number = PROC_BIND_SENTINEL_READY_POLL_MS,
491
+ now: () => number = Date.now,
492
+ ): Promise<boolean> {
493
+ const deadline = now() + timeoutMs
494
+ for (;;) {
495
+ if (await readMarker(sentinelPid)) return true
496
+ if (now() >= deadline) return false
497
+ await sleep(pollMs)
498
+ }
499
+ }
500
+
501
+ export const _waitForSentinelMarkerForTests = waitForSentinelMarker
502
+
460
503
  export function _resetProcBindProbeCacheForTests(): void {
461
504
  procBindProbeCache.clear()
462
505
  procBindProbeInFlight.clear()
@@ -41,3 +41,29 @@ export class SandboxDegradedProcError extends Error {
41
41
  )
42
42
  }
43
43
  }
44
+
45
+ // Distinct from SandboxDegradedProcError: that one is the PERMANENT verdict (a
46
+ // real userns leak, or a host with no usable namespaces — retrying is futile).
47
+ // This one fires when the proc-bind safety probe stayed 'inconclusive' through
48
+ // its whole retry budget — typically a boot-time CPU/IO storm tripping the
49
+ // probe's own timeout. The host is very likely capable; the probe just couldn't
50
+ // prove it RIGHT NOW. Because an 'inconclusive' verdict is never cached, the next
51
+ // bash call re-probes from scratch and usually promotes to proc-bind once the
52
+ // spike passes. So the message tells the model the OPPOSITE of the permanent
53
+ // case: retrying IS the fix. Without this split, a single unlucky boot-storm
54
+ // probe degraded a fully-capable container to tmpfs and told the agent it was a
55
+ // permanent environment limit — so it gave up instead of retrying.
56
+ export class SandboxProcProbeUnverifiedError extends Error {
57
+ override readonly name = 'SandboxProcProbeUnverifiedError'
58
+ constructor() {
59
+ super(
60
+ 'sandbox /proc strategy could not be verified right now: the cap-free ' +
61
+ 'proc-bind safety probe stayed inconclusive (usually transient load on the ' +
62
+ 'host while the container was starting up), so this bun package command ' +
63
+ '(bun install / bun add / bunx / bun run) was held back rather than run ' +
64
+ 'under a broken /proc. This is almost certainly temporary and NOT a problem ' +
65
+ 'with the command or the package: retry the SAME command in a few seconds — ' +
66
+ 'the next attempt re-probes and normally succeeds.',
67
+ )
68
+ }
69
+ }
@@ -27,7 +27,12 @@ export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
27
27
  export { commandNeedsRealProc, isPackageInstallCommand } from './package-install'
28
28
  export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
29
29
  export { formatCommand, shellQuote } from './quote'
30
- export { SandboxDegradedProcError, SandboxPolicyError, SandboxUnavailableError } from './errors'
30
+ export {
31
+ SandboxDegradedProcError,
32
+ SandboxPolicyError,
33
+ SandboxProcProbeUnverifiedError,
34
+ SandboxUnavailableError,
35
+ } from './errors'
31
36
  export {
32
37
  DEFAULT_SANDBOX_ENV,
33
38
  type SandboxCommandFilter,
@@ -16,7 +16,7 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
16
16
  - `port` — the TCP port the websocket server binds to inside the container. The TUI on the host stage connects to this. Default `8973`. **Restart-required.**
17
17
  - `model` — a fully-qualified `<provider>/<model-id>` string. The runtime resolves this against the built-in provider registry to decide which API to call for every turn. **Live-reloadable.**
18
18
  - `mounts` — additional host directories the user has chosen to expose to you. Each entry produces a `docker run -v <hostPath>:/agent/mounts/<name>` flag at `typeclaw start` time, so the directory shows up at `mounts/<name>` inside your agent folder. **The launcher reads this; the running container does not.** Editing `mounts` only takes effect on the next `typeclaw start`. **Restart-required.**
19
- - `plugins` — array of plugin package names loaded at server boot. **Restart-required.**
19
+ - `plugins` — array of plugin module specifiers loaded at server boot: npm package names for published plugins, or relative paths for local plugins you are authoring. **Restart-required.**
20
20
  - `alias` — additional names the agent answers to when a channel message contains its name in plain text (no `<@id>` mention). The agent folder's directory name (`basename(agentDir)`) is always implicit; `alias` adds further forms (Latin transliteration, nicknames, Korean particles, etc.). Used by the channel engagement layer alongside the structural mention/reply/dm triggers. **Live-reloadable.**
21
21
  - `channels` — per-adapter engagement triggers and history-prefetch knobs for external messengers (Discord, Slack, Telegram, KakaoTalk), plus the GitHub channel (a webhook-driven adapter that watches repos and reviews PRs — see **GitHub channel** below). Access control lives in `roles`, not here. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
22
22
  - `docker.file` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated package installs — `tmux`, `gh`, `python`, `xvfb` default on (`true`); `cjkFonts` defaults to `"auto"` (resolved from host locale at start); `ffmpeg`, `cloudflared`, `claudeCode`, `codexCli` default off (`false`) — set a toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python`, `cjkFonts`, `cloudflared`, `xvfb`, `claudeCode`, and `codexCli` are boolean-only). Most toggles install apt packages with BuildKit cache mounts; `cloudflared`, `claudeCode`, and `codexCli` are exceptions — `cloudflared` downloads the pinned GitHub release, `claudeCode` runs Anthropic's official `curl | bash` installer, `codexCli` `bun install`s the `@openai/codex` npm package. (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. Lives under the `docker` namespace alongside future Docker-related blocks (e.g. `docker.compose`). **Restart-required** (next `typeclaw start` rebuilds the image).
@@ -45,7 +45,7 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
45
45
  | `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.** |
46
46
  | `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.** |
47
47
  | `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.** |
48
- | `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
48
+ | `plugins` | no | array of strings | Plugin module specifiers loaded at server boot: use npm package names for published plugins (for example, `typeclaw-gws-multi-account`) and relative paths only for local plugins you are authoring (for example, `./packages/my-plugin`). Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
49
49
  | `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Channels and Alias** below for schema/edit mechanics; the matching behavior lives in the `typeclaw-channels` skill. **Live-reloadable.** |
50
50
  | `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers (plus the `github` webhook channel — see **GitHub channel** below). Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill; engagement behavior lives in `typeclaw-channels`. **Live-reloadable.** See **Channels and Alias** below. |
51
51
  | `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below. |
@@ -11,10 +11,10 @@ Your agent folder is a **bun monorepo**. The root `package.json` declares `"work
11
11
 
12
12
  You have two free-write zones at the agent root: `workspace/` and `packages/`. Both are exempt from the non-workspace-write guard so you can edit them without acknowledging anything, but their relationship to git is opposite, and picking the wrong one is the most common mistake.
13
13
 
14
- | Zone | Purpose | Tracked in git? | Reusable? |
15
- | ------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
16
- | `workspace/` | One-off scripts, scratch work, throwaway experiments | **No** — entire dir is gitignored | No (the dir itself is invisible to git) |
17
- | `packages/` | Reusable packages, custom plugins, shared utilities, internal libs | **Yes** — every file is tracked and MUST be committed when edited (only `*/node_modules/` ignored inside) | Yes (committed and importable across agents) |
14
+ | Zone | Purpose | Tracked in git? | Reusable? |
15
+ | ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
16
+ | `workspace/` | One-off scripts, scratch work, throwaway experiments | **No** — entire dir is gitignored | No (the dir itself is invisible to git) |
17
+ | `packages/` | Reusable packages, custom local plugins, shared utilities, internal libs | **Yes** — every file is tracked and MUST be committed when edited (only `*/node_modules/` ignored inside) | Yes (committed and importable across agents) |
18
18
 
19
19
  The two columns to internalize:
20
20
 
@@ -26,7 +26,7 @@ Anything you put in `packages/` MUST land in a commit — see `typeclaw-git`. Th
26
26
  **Decision rule, top to bottom — stop at the first match:**
27
27
 
28
28
  1. **Will another script or another part of the agent folder import this?** → `packages/<name>/`. Even if "another part" is just "tomorrow's me writing a sibling script", a reusable thing belongs here.
29
- 2. **Is this a custom typeclaw plugin** (anything you'd list in `typeclaw.json`'s `plugins`)? → `packages/<plugin-name>/`. Always. Plugins are the canonical packages.
29
+ 2. **Is this a custom local typeclaw plugin you are authoring?** → `packages/<plugin-name>/`. If you are adding an existing or published plugin, keep its npm package specifier in `typeclaw.json#plugins`; do not create or guess a `./packages/...` path.
30
30
  3. **Will the user want to track this in git, see it in PRs, depend on it from a cron job?** → `packages/<name>/`.
31
31
  4. **Is this throwaway** — a one-shot data transformation, a debug script, a scratch experiment that exists for one task and dies? → `workspace/`.
32
32
  5. **Default if unsure** → `packages/<name>/`. Better to commit something reusable than to lose something useful in the gitignored void.
@@ -97,6 +97,8 @@ To depend on a workspace package from the **agent root** (e.g. so cron `exec` jo
97
97
 
98
98
  ## Custom typeclaw plugins live under `packages/`
99
99
 
100
+ This section is only for plugins you are **authoring locally** in the agent folder. If the user asks to add/install an existing or published plugin, use the plugin's npm package specifier in `typeclaw.json#plugins` (for example, `"typeclaw-gws-multi-account"`) and do **not** fabricate a `./packages/...` path.
101
+
100
102
  If you are writing a typeclaw plugin (anything that uses `definePlugin` from `typeclaw/plugin`), the canonical home is `packages/<plugin-name>/`. The workflow:
101
103
 
102
104
  1. **Author**: `packages/my-plugin/index.ts` exports `definePlugin({ ... })` as default.
@@ -115,6 +115,13 @@ Without `configSchema`, `ctx.config` is `never` and any reference is a type erro
115
115
 
116
116
  The **derived name is the key** for the per-plugin config block at the top level of `typeclaw.json`. Two plugins with the same derived name are a boot error.
117
117
 
118
+ Use the entry format that matches the plugin's source:
119
+
120
+ - **Published npm plugin** → put the npm package specifier in `plugins[]`, e.g. `"typeclaw-gws-multi-account"` or `"typeclaw-plugin-standup-log@1.2.3"`. Do **not** invent a `./packages/...` path for a published package.
121
+ - **Local plugin you are authoring in this agent folder** → put its relative path in `plugins[]`, e.g. `"./packages/my-plugin"`. The path must exist and point at local plugin code.
122
+
123
+ If the user says to add/install an existing plugin by package name, preserve that package name. Only use `./packages/<name>` when you are creating or wiring a local workspace package that exists in this repo.
124
+
118
125
  ### Local path safety
119
126
 
120
127
  Local plugin paths **must resolve inside `agentDir`**. Absolute paths (`/etc/...`) and parent-traversing paths (`../../foo`) are rejected with:
@@ -125,9 +132,11 @@ plugin path escapes agent directory: <entry> (resolved to <abs-path>)
125
132
 
126
133
  This is why `./plugins/x.ts` works and `/Users/me/x.ts` does not.
127
134
 
128
- ### Recommended location: `packages/<plugin-name>/`
135
+ ### Recommended location for new local plugins: `packages/<plugin-name>/`
136
+
137
+ This section is about plugins you are **authoring locally**. For a published npm plugin, keep the npm package specifier in `plugins[]`; do not create or guess a local path.
129
138
 
130
- The agent folder is a **bun monorepo**, and `packages/` is its workspace root. **Custom plugins go there.** A `./packages/standup-log/` plugin is a real workspace package — bun installs its dependencies, the workspace symlink machinery makes it importable, and it lands in git like any other reusable code. Concretely:
139
+ The agent folder is a **bun monorepo**, and `packages/` is its workspace root. **Custom local plugins go there.** A `./packages/standup-log/` plugin is a real workspace package — bun installs its dependencies, the workspace symlink machinery makes it importable, and it lands in git like any other reusable code. Concretely:
131
140
 
132
141
  ```
133
142
  packages/