typeclaw 0.5.0 → 0.5.1

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.5.0",
3
+ "version": "0.5.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -25,7 +25,7 @@ import {
25
25
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
26
26
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
27
27
 
28
- import { c, done, errorLine } from './ui'
28
+ import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
29
29
 
30
30
  const CHANNEL_LABELS: Record<ChannelKind, string> = {
31
31
  'slack-bot': 'Slack',
@@ -834,50 +834,7 @@ async function promptDiscordToken(): Promise<string> {
834
834
  }
835
835
 
836
836
  async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
837
- note(
838
- [
839
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
840
- ' Pick your workspace, then paste this JSON manifest:',
841
- '',
842
- ' {',
843
- ' "display_information": { "name": "TypeClaw" },',
844
- ' "features": {',
845
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
846
- ' },',
847
- ' "oauth_config": {',
848
- ' "scopes": {',
849
- ' "bot": [',
850
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
851
- ' "channels:history", "channels:read",',
852
- ' "groups:history", "groups:read",',
853
- ' "im:history", "im:read",',
854
- ' "mpim:history", "mpim:read"',
855
- ' ]',
856
- ' }',
857
- ' },',
858
- ' "settings": {',
859
- ' "event_subscriptions": {',
860
- ' "bot_events": [',
861
- ' "app_mention",',
862
- ' "message.channels", "message.groups",',
863
- ' "message.im", "message.mpim"',
864
- ' ]',
865
- ' },',
866
- ' "socket_mode_enabled": true',
867
- ' }',
868
- ' }',
869
- '',
870
- '2. Install to Workspace, then OAuth & Permissions →',
871
- ' copy the Bot User OAuth Token (xoxb-...).',
872
- '3. Basic Information → App-Level Tokens → Generate Token and',
873
- ' Scopes, add the connections:write scope, and copy the',
874
- ' token (xapp-...). Socket Mode needs this; the manifest',
875
- ' cannot grant it.',
876
- '4. Invite the bot to any private channel or DM you want it in:',
877
- ' /invite @TypeClaw',
878
- ].join('\n'),
879
- 'Get a Slack bot',
880
- )
837
+ printSlackAppManifestSetup()
881
838
  const bot = await promptSlackBotToken()
882
839
  note(
883
840
  [
package/src/cli/init.ts CHANGED
@@ -32,7 +32,7 @@ import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
32
32
  import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
33
33
 
34
34
  import { buildOAuthCallbacks } from './oauth-callbacks'
35
- import { c, done, errorLine } from './ui'
35
+ import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
36
36
 
37
37
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
38
38
  // aliases both to the same "cancel" action — there's no way to tell them
@@ -1005,50 +1005,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
1005
1005
  let sub: SubStep = 'bot'
1006
1006
  let botToken: string | undefined
1007
1007
 
1008
- note(
1009
- [
1010
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
1011
- ' Pick your workspace, then paste this JSON manifest:',
1012
- '',
1013
- ' {',
1014
- ' "display_information": { "name": "TypeClaw" },',
1015
- ' "features": {',
1016
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
1017
- ' },',
1018
- ' "oauth_config": {',
1019
- ' "scopes": {',
1020
- ' "bot": [',
1021
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
1022
- ' "channels:history", "channels:read",',
1023
- ' "groups:history", "groups:read",',
1024
- ' "im:history", "im:read",',
1025
- ' "mpim:history", "mpim:read"',
1026
- ' ]',
1027
- ' }',
1028
- ' },',
1029
- ' "settings": {',
1030
- ' "event_subscriptions": {',
1031
- ' "bot_events": [',
1032
- ' "app_mention",',
1033
- ' "message.channels", "message.groups",',
1034
- ' "message.im", "message.mpim"',
1035
- ' ]',
1036
- ' },',
1037
- ' "socket_mode_enabled": true',
1038
- ' }',
1039
- ' }',
1040
- '',
1041
- '2. Install to Workspace, then OAuth & Permissions →',
1042
- ' copy the Bot User OAuth Token (xoxb-...).',
1043
- '3. Basic Information → App-Level Tokens → Generate Token and',
1044
- ' Scopes, add the connections:write scope, and copy the',
1045
- ' token (xapp-...). Socket Mode needs this; the manifest',
1046
- ' cannot grant it.',
1047
- '4. Invite the bot to any private channel or DM you want it in:',
1048
- ' /invite @TypeClaw',
1049
- ].join('\n'),
1050
- 'Get a Slack bot',
1051
- )
1008
+ printSlackAppManifestSetup()
1052
1009
 
1053
1010
  while (true) {
1054
1011
  if (sub === 'bot') {
package/src/cli/model.ts CHANGED
@@ -25,7 +25,7 @@ const ADD_PROVIDER_SENTINEL = '__add-provider__'
25
25
  const setSub = defineCommand({
26
26
  meta: {
27
27
  name: 'set',
28
- description: 'set or update a model profile (default | fast | vision | <custom>)',
28
+ description: 'set or update a model profile (default | fast | deep | vision | <custom>)',
29
29
  },
30
30
  args: {
31
31
  profile: {
@@ -206,6 +206,7 @@ async function pickProfileName(): Promise<string> {
206
206
  options: [
207
207
  { value: 'default', label: 'default', hint: 'active model for new sessions' },
208
208
  { value: 'fast', label: 'fast', hint: 'optional alias used by some subagents' },
209
+ { value: 'deep', label: 'deep', hint: 'optional alias used by some subagents' },
209
210
  { value: 'vision', label: 'vision', hint: 'optional alias used by some subagents' },
210
211
  ],
211
212
  initialValue: 'default',
package/src/cli/ui.ts CHANGED
@@ -124,3 +124,98 @@ export function errorLine(reason: string): string {
124
124
  export function successLine(message: string): string {
125
125
  return `${c.green('●')} ${message}`
126
126
  }
127
+
128
+ // The exact JSON manifest a user pastes into
129
+ // https://api.slack.com/apps → From a manifest. Kept as a typed object so
130
+ // the file stays a single source of truth and `JSON.stringify` guarantees
131
+ // the rendered text is always valid JSON — no risk of a stray comma or
132
+ // quote slipping in through hand-formatting.
133
+ export const SLACK_APP_MANIFEST = {
134
+ display_information: { name: 'TypeClaw' },
135
+ features: {
136
+ bot_user: { display_name: 'TypeClaw', always_online: true },
137
+ // Enable the Messages tab so users can DM the bot from its app profile,
138
+ // and disable the Home tab — TypeClaw does not publish a custom App Home
139
+ // view, and leaving it enabled would surface an empty default tab.
140
+ app_home: {
141
+ home_tab_enabled: false,
142
+ messages_tab_enabled: true,
143
+ messages_tab_read_only_enabled: false,
144
+ },
145
+ },
146
+ oauth_config: {
147
+ scopes: {
148
+ // Ordered alphabetically so the manifest stays a stable diff target.
149
+ // Read scopes cover every conversation type the agent might observe;
150
+ // write scopes (chat, files, im/mpim/groups, pins, reactions) let the
151
+ // agent post replies, upload attachments, open DMs, pin messages, and
152
+ // react to messages. `channels:join` lets the bot self-join public
153
+ // channels it's invited to discuss in.
154
+ bot: [
155
+ 'app_mentions:read',
156
+ 'channels:history',
157
+ 'channels:join',
158
+ 'channels:read',
159
+ 'chat:write',
160
+ 'emoji:read',
161
+ 'files:read',
162
+ 'files:write',
163
+ 'groups:history',
164
+ 'groups:read',
165
+ 'groups:write',
166
+ 'im:history',
167
+ 'im:read',
168
+ 'im:write',
169
+ 'mpim:history',
170
+ 'mpim:read',
171
+ 'mpim:write',
172
+ 'pins:read',
173
+ 'pins:write',
174
+ 'reactions:read',
175
+ 'reactions:write',
176
+ 'users:read',
177
+ ],
178
+ },
179
+ },
180
+ settings: {
181
+ event_subscriptions: {
182
+ bot_events: ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'],
183
+ },
184
+ socket_mode_enabled: true,
185
+ },
186
+ } as const
187
+
188
+ // Prints the "create a Slack app from a manifest" walkthrough so the JSON
189
+ // payload is **flush-left and copy-pasteable**. Clack's `note()` wraps
190
+ // content inside a box with `│` borders on both sides, and `log.message()`
191
+ // still prefixes every line with a `│ ` guide column — neither survives a
192
+ // click-and-drag copy. This helper splits the walkthrough into three
193
+ // segments: a boxed prose intro, a raw-stdout JSON block, and a boxed
194
+ // follow-up. The JSON block is emitted via `process.stdout.write` so it
195
+ // carries zero terminal decoration.
196
+ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = process.stdout): void {
197
+ note(
198
+ [
199
+ '1. https://api.slack.com/apps → Create New App → From a manifest.',
200
+ ' Pick your workspace, then paste the JSON manifest printed below',
201
+ ` (it is rendered flush-left so you can ${c.bold('click-drag and copy')} cleanly).`,
202
+ ].join('\n'),
203
+ 'Get a Slack bot',
204
+ )
205
+ output.write('\n')
206
+ output.write(`${JSON.stringify(SLACK_APP_MANIFEST, null, 2)}\n`)
207
+ output.write('\n')
208
+ note(
209
+ [
210
+ '2. Install to Workspace, then OAuth & Permissions →',
211
+ ' copy the Bot User OAuth Token (xoxb-...).',
212
+ '3. Basic Information → App-Level Tokens → Generate Token and',
213
+ ' Scopes, add the connections:write scope, and copy the',
214
+ ' token (xapp-...). Socket Mode needs this; the manifest',
215
+ ' cannot grant it.',
216
+ '4. Invite the bot to any private channel or DM you want it in:',
217
+ ' /invite @TypeClaw',
218
+ ].join('\n'),
219
+ 'Finish Slack setup',
220
+ )
221
+ }
@@ -561,6 +561,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
561
561
 
562
562
  ${LAYER_4_AGENT_BROWSER_INSTALL}
563
563
 
564
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
565
+
564
566
  ${LAYER_5_CHROME_FOR_TESTING}
565
567
 
566
568
  ${cloudflaredLayer}${renderEntrypointShimLayer()}
@@ -638,6 +640,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
638
640
 
639
641
  ${LAYER_4_AGENT_BROWSER_INSTALL}
640
642
 
643
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
644
+
641
645
  ${LAYER_5_CHROME_FOR_TESTING}
642
646
 
643
647
  ${renderEntrypointShimLayer()}
@@ -699,6 +703,114 @@ const LAYER_4_AGENT_BROWSER_INSTALL = `# Layer 4 (volatile): install agent-brows
699
703
  RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
700
704
  bun install -g agent-browser`
701
705
 
706
+ // Layer 4.5: shim the agent-browser binary with a wrapper that calls
707
+ // \`agent-browser close\` before \`open\`/\`goto\`/\`navigate\` when headed
708
+ // mode is requested. Works around vercel-labs/agent-browser issue #1083
709
+ // ("headed silently ignored on existing session"): when a daemon is
710
+ // already running with a headless browser, subsequent commands with
711
+ // --headed / AGENT_BROWSER_HEADED reuse the existing headless browser
712
+ // regardless of the requested mode. Three upstream fix PRs (#660, #370,
713
+ // #387) have been open and unmerged for months as of 2026-05, so we
714
+ // patch this locally rather than block on upstream.
715
+ //
716
+ // Allowlist, not denylist. The wrapper only pre-closes on the three
717
+ // commands that explicitly start a new browsing session (\`open\`,
718
+ // \`goto\`, \`navigate\`). Every other agent-browser subcommand — \`click\`,
719
+ // \`snapshot\`, \`chat\`, \`connect\`, \`batch\`, \`tab\`, \`record\`, \`trace\`,
720
+ // \`stream\`, \`cookies\`, \`network\`, ... — passes through untouched.
721
+ // Rationale: those subcommands may operate on the live browser/page
722
+ // state (cookies, in-progress recording, attached external CDP, etc.),
723
+ // and a pre-close from us would silently destroy it. The user-reported
724
+ // scenario for #1083 (\"\`agent-browser open <url> --headed\` after a
725
+ // previous headless invocation\") is fully covered because the
726
+ // follow-up commands inherit the now-headed browser the \`open\`
727
+ // pre-close forced. An earlier draft used a deny-list approach that
728
+ // pre-closed on every non-skip subcommand under headed env; oracle
729
+ // self-review flagged the state-destruction risk for stateful commands,
730
+ // and the allowlist fix is the resulting narrower contract.
731
+ //
732
+ // Truthy contract mirrors upstream's \`env_var_is_truthy\`
733
+ // (cli/src/flags.rs:183): any non-empty value EXCEPT case-insensitive
734
+ // "0" / "false" / "no" counts as truthy. So
735
+ // \`AGENT_BROWSER_HEADED=yes\`, \`=y\`, \`=on\`, \`=anything-non-falsy\` all
736
+ // trigger the workaround — matching what upstream's CLI parser would
737
+ // see — instead of the original narrower 1|true match that left the
738
+ // bug present for legitimate truthy values.
739
+ //
740
+ // Re-entrancy is defended at two layers. (1) The pre-close path is
741
+ // \`open\`/\`goto\`/\`navigate\` only, and the close subcommand isn't in the
742
+ // allowlist, so the pre-close never recurses through the wrapper into
743
+ // another pre-close. (2) \`_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1\` is
744
+ // set on the env passed to both the pre-close and the final exec; if a
745
+ // future subcommand we don't recognize shells out to \`agent-browser\` as
746
+ // a subprocess while headed env is still set, the child sees the guard
747
+ // and bypasses straight to .real without recursing.
748
+ const LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER = `# Layer 4.5 (cheap): wrap agent-browser to work around upstream issue
749
+ # #1083 (--headed / AGENT_BROWSER_HEADED ignored on existing session).
750
+ # See src/init/dockerfile.ts for the full rationale.
751
+ RUN mv /usr/local/bin/agent-browser /usr/local/bin/agent-browser.real \\
752
+ && cat > /usr/local/bin/agent-browser <<'TYPECLAW_AGENT_BROWSER_WRAPPER_EOF' \\
753
+ && chmod +x /usr/local/bin/agent-browser
754
+ #!/bin/sh
755
+ # typeclaw wrapper for agent-browser — see src/init/dockerfile.ts.
756
+ set -e
757
+ real="\${TYPECLAW_AGENT_BROWSER_REAL:-/usr/local/bin/agent-browser.real}"
758
+ # Re-entrancy guard: if the wrapper invoked us, skip straight to the real
759
+ # binary. Prevents infinite recursion if a subcommand shells out to
760
+ # agent-browser while AGENT_BROWSER_HEADED is still set.
761
+ if [ "\${_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED:-}" = "1" ]; then
762
+ exec "$real" "$@"
763
+ fi
764
+ # Pre-close is only needed when the caller is requesting headed mode.
765
+ # Match upstream's env_var_is_truthy contract (cli/src/flags.rs:183):
766
+ # truthy = any non-empty value except case-insensitive "0", "false", "no".
767
+ # Argv triggers: bare --headed, --headed=true, --headed=1. (A bare
768
+ # --headed followed by a separate "false" argument is upstream-supported
769
+ # to FORCE headless; the wrapper still pre-closes on the --headed match
770
+ # and the real binary launches headless — wasted close, correct end
771
+ # state. The narrower argv match keeps the wrapper from triggering on
772
+ # unrelated --headed-prefixed flags that may exist in future upstream
773
+ # versions.)
774
+ headed=0
775
+ val=\${AGENT_BROWSER_HEADED:-}
776
+ lower=$(printf '%s' "$val" | tr '[:upper:]' '[:lower:]')
777
+ case "$lower" in
778
+ ''|'0'|'false'|'no') ;;
779
+ *) headed=1 ;;
780
+ esac
781
+ for arg in "$@"; do
782
+ case "$arg" in
783
+ --headed|--headed=true|--headed=1) headed=1; break ;;
784
+ esac
785
+ done
786
+ if [ "$headed" != "1" ]; then
787
+ exec "$real" "$@"
788
+ fi
789
+ # Allowlist of commands where pre-close is safe and necessary. Only
790
+ # user-visible "start a new browsing session" verbs go here. Everything
791
+ # else (click, snapshot, chat, connect, batch, tab, record, trace,
792
+ # stream, cookies, ...) may depend on live browser/page state and must
793
+ # not be pre-closed by us.
794
+ first=""
795
+ for arg in "$@"; do
796
+ case "$arg" in
797
+ -*) continue ;;
798
+ *) first="$arg"; break ;;
799
+ esac
800
+ done
801
+ case "$first" in
802
+ open|goto|navigate) ;;
803
+ *) exec "$real" "$@" ;;
804
+ esac
805
+ # Best-effort pre-close. If the daemon is already gone, the real binary
806
+ # prints "No active sessions" and exits 0 — safe to call unconditionally.
807
+ # We discard its output so it never pollutes the caller's stdout/stderr,
808
+ # and we tolerate failures (network blip, stale socket) by falling
809
+ # through to the real command anyway.
810
+ _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" close >/dev/null 2>&1 || true
811
+ exec env _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" "$@"
812
+ TYPECLAW_AGENT_BROWSER_WRAPPER_EOF`
813
+
702
814
  // Layer 5: download the pinned Chrome for Testing build into
703
815
  // ~/.agent-browser/browsers/. NO cache mount on that path because the
704
816
  // runtime needs the binary in the image. System shared libraries are
package/src/init/index.ts CHANGED
@@ -37,6 +37,14 @@ const CONFIG_FILE = 'typeclaw.json'
37
37
  const CRON_FILE = 'cron.json'
38
38
  const PACKAGE_FILE = 'package.json'
39
39
 
40
+ // Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
41
+ // (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
42
+ // matches every channel session on every platform, so the built-in `member`
43
+ // role (which already carries `channel.respond`) covers any inbound the
44
+ // router sees. Without this, freshly-hatched agents silently drop every
45
+ // chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
46
+ const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
47
+
40
48
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
41
49
 
42
50
  // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
@@ -543,6 +551,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
543
551
  if (options.withTelegram) channels['telegram-bot'] = {}
544
552
  if (options.withKakaotalk) channels.kakaotalk = {}
545
553
  if (Object.keys(channels).length > 0) config.channels = channels
554
+ // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
555
+ // separately (writeGithubChannelForInit) and seeds per-repo member.match
556
+ // entries instead of the wildcard, so a github-only init stays scoped to
557
+ // the repos the operator opted in to.
558
+ if (Object.keys(channels).length > 0) config.roles = { member: { match: [DEFAULT_CHAT_MEMBER_MATCH_RULE] } }
546
559
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
547
560
 
548
561
  const cron = {
@@ -965,6 +978,8 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
965
978
  if (options.channel === 'github') {
966
979
  await appendGithubMatchRules(options.cwd, options.repos)
967
980
  await maybeInstallGithubWebhooks(options, emit)
981
+ } else {
982
+ await ensureDefaultChatMemberMatch(options.cwd)
968
983
  }
969
984
 
970
985
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1209,6 +1224,24 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1209
1224
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1210
1225
  }
1211
1226
 
1227
+ // Chat-adapter counterpart of appendGithubMatchRules. See
1228
+ // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1229
+ // running `typeclaw channel add` for additional chat adapters is a no-op on
1230
+ // the match list, and any pre-existing rules the operator hand-authored
1231
+ // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1232
+ async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1233
+ const path = join(cwd, CONFIG_FILE)
1234
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1235
+ const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1236
+ const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1237
+ const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1238
+ if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1239
+ member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1240
+ roles.member = member
1241
+ parsed.roles = roles
1242
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1243
+ }
1244
+
1212
1245
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1213
1246
  // Refuses to overwrite existing fields: if the user already has e.g.
1214
1247
  // `botToken` recorded (from a prior `channel add` whose follow-up steps