typeclaw 0.7.0 → 0.8.0
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/README.md +9 -3
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -1
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +61 -8
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +95 -13
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/system-prompt.ts +40 -8
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/router.ts +127 -1
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/init/dockerfile.ts +89 -2
- package/src/shared/index.ts +1 -1
- package/src/shared/local-time.ts +17 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +30 -5
- package/src/skills/typeclaw-config/SKILL.md +37 -32
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +1 -1
package/src/channels/router.ts
CHANGED
|
@@ -297,9 +297,30 @@ type LiveSession = {
|
|
|
297
297
|
unsubProviderErrors: (() => void) | null
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// `event` is null for command invocations that originated outside the inbound
|
|
301
|
+
// pipeline (e.g. Discord native slash commands fired from listener.on
|
|
302
|
+
// ('interaction_create')). Handlers that need a real inbound — for some
|
|
303
|
+
// future hypothetical command like `/quote` — must guard on event !== null
|
|
304
|
+
// instead of assuming it.
|
|
300
305
|
type ChannelCommandContext = {
|
|
301
306
|
live: LiveSession
|
|
302
|
-
event: InboundMessage
|
|
307
|
+
event: InboundMessage | null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type ExecuteCommandResult =
|
|
311
|
+
| { kind: 'handled'; name: string }
|
|
312
|
+
| { kind: 'unknown-command'; name: string }
|
|
313
|
+
| { kind: 'no-live-session' }
|
|
314
|
+
| { kind: 'permission-denied' }
|
|
315
|
+
| { kind: 'ambiguous'; matchCount: number }
|
|
316
|
+
|
|
317
|
+
// Identifies who invoked an adapter-driven command. Required so the router
|
|
318
|
+
// can run the same channel.respond permission gate the text-prefix command
|
|
319
|
+
// path runs (isChannelRespondDenied in route()). Without it, a guest user
|
|
320
|
+
// in a public Slack channel could /stop an owner-created session that
|
|
321
|
+
// happened to be live, bypassing role gating entirely.
|
|
322
|
+
export type ExecuteCommandOptions = {
|
|
323
|
+
invokerId: string
|
|
303
324
|
}
|
|
304
325
|
|
|
305
326
|
export type SendSource = 'tool' | 'system'
|
|
@@ -345,6 +366,22 @@ export type ChannelRouter = {
|
|
|
345
366
|
registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
346
367
|
unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
347
368
|
fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
369
|
+
// Execute a command by name against an existing live session, bypassing
|
|
370
|
+
// the inbound classifier, engagement gate, debounce, and prompt queue.
|
|
371
|
+
// Used by adapters that receive commands through a native surface
|
|
372
|
+
// (Discord application-command interactions) rather than text. Gates
|
|
373
|
+
// the invoker on channel.respond — same permission gate the text-prefix
|
|
374
|
+
// command path runs — so a guest user cannot abort an owner's session
|
|
375
|
+
// by clicking the slash-command picker. Adapters MUST forward the
|
|
376
|
+
// invoker's platform-specific user id; without it the gate cannot
|
|
377
|
+
// identify the actor and resolves to 'guest' which denies. Returns:
|
|
378
|
+
// - handled: command ran
|
|
379
|
+
// - permission-denied: invoker lacks channel.respond
|
|
380
|
+
// - no-live-session: channel has no active session
|
|
381
|
+
// - ambiguous: multiple thread-keyed sessions in same chat (Slack);
|
|
382
|
+
// caller should refuse to act rather than abort an arbitrary one
|
|
383
|
+
// - unknown-command: name is not registered
|
|
384
|
+
executeCommand: (key: ChannelKey, name: string, options: ExecuteCommandOptions) => Promise<ExecuteCommandResult>
|
|
348
385
|
// Lowered self-aliases (configured + implicit dir-name). Adapters use
|
|
349
386
|
// this to anchor outbound threading on alias-only inbounds — see
|
|
350
387
|
// slack-bot-classify.ts. Read live so a reload of `alias` propagates
|
|
@@ -1733,6 +1770,48 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1733
1770
|
}
|
|
1734
1771
|
}
|
|
1735
1772
|
|
|
1773
|
+
const executeCommand = async (
|
|
1774
|
+
key: ChannelKey,
|
|
1775
|
+
name: string,
|
|
1776
|
+
options: ExecuteCommandOptions,
|
|
1777
|
+
): Promise<ExecuteCommandResult> => {
|
|
1778
|
+
const lowered = name.toLowerCase()
|
|
1779
|
+
if (!commands.has(lowered)) {
|
|
1780
|
+
return { kind: 'unknown-command', name: lowered }
|
|
1781
|
+
}
|
|
1782
|
+
// Permission gate runs BEFORE the live-session lookup so a guest user
|
|
1783
|
+
// invoking /stop on a non-existent session gets 'permission-denied'
|
|
1784
|
+
// (consistent answer regardless of session state) rather than leaking
|
|
1785
|
+
// session presence via the 'no-live-session' vs 'permission-denied'
|
|
1786
|
+
// distinction.
|
|
1787
|
+
const partial: SessionOrigin = {
|
|
1788
|
+
kind: 'channel',
|
|
1789
|
+
adapter: key.adapter,
|
|
1790
|
+
workspace: key.workspace,
|
|
1791
|
+
chat: key.chat,
|
|
1792
|
+
thread: key.thread,
|
|
1793
|
+
lastInboundAuthorId: options.invokerId,
|
|
1794
|
+
}
|
|
1795
|
+
if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
|
|
1796
|
+
return { kind: 'permission-denied' }
|
|
1797
|
+
}
|
|
1798
|
+
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
1799
|
+
if (resolved.kind === 'none') {
|
|
1800
|
+
return { kind: 'no-live-session' }
|
|
1801
|
+
}
|
|
1802
|
+
if (resolved.kind === 'ambiguous') {
|
|
1803
|
+
return { kind: 'ambiguous', matchCount: resolved.count }
|
|
1804
|
+
}
|
|
1805
|
+
const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
|
|
1806
|
+
if (result.kind === 'handled') {
|
|
1807
|
+
return { kind: 'handled', name: result.name }
|
|
1808
|
+
}
|
|
1809
|
+
// commands.execute can only return not-command (impossible — we pass a
|
|
1810
|
+
// leading slash), unknown-command (impossible — we just checked has()),
|
|
1811
|
+
// or handled. Any other outcome is a bug.
|
|
1812
|
+
return { kind: 'unknown-command', name: lowered }
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1736
1815
|
return {
|
|
1737
1816
|
route,
|
|
1738
1817
|
send,
|
|
@@ -1752,6 +1831,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1752
1831
|
registerFetchAttachment,
|
|
1753
1832
|
unregisterFetchAttachment,
|
|
1754
1833
|
fetchAttachment,
|
|
1834
|
+
executeCommand,
|
|
1755
1835
|
getSelfAliases: computeSelfAliases,
|
|
1756
1836
|
stop,
|
|
1757
1837
|
liveCount: () => liveSessions.size,
|
|
@@ -1912,6 +1992,52 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
|
|
|
1912
1992
|
return `${chat}:${thread ?? ''}`
|
|
1913
1993
|
}
|
|
1914
1994
|
|
|
1995
|
+
export type ResolveLiveSessionResult =
|
|
1996
|
+
| { kind: 'found'; session: LiveSession }
|
|
1997
|
+
| { kind: 'none' }
|
|
1998
|
+
| { kind: 'ambiguous'; count: number }
|
|
1999
|
+
|
|
2000
|
+
// Lookup policy for adapter-driven commands. Exact-key match always wins.
|
|
2001
|
+
// On miss, fall back to (adapter, workspace, chat) without thread — but
|
|
2002
|
+
// only when EXACTLY ONE non-destroyed candidate exists. Ambiguous matches
|
|
2003
|
+
// return 'ambiguous' so the caller can refuse to act rather than abort an
|
|
2004
|
+
// arbitrary session.
|
|
2005
|
+
//
|
|
2006
|
+
// Why the fallback: Slack slash commands carry channel_id but no thread_ts,
|
|
2007
|
+
// so a slash invocation from a thread-keyed live session would otherwise
|
|
2008
|
+
// report no-live-session. Discord doesn't hit this — Discord treats threads
|
|
2009
|
+
// as channels, so the exact-key path already resolves.
|
|
2010
|
+
//
|
|
2011
|
+
// Why ambiguity-rejection: "first match wins" map-iteration semantics would
|
|
2012
|
+
// abort an arbitrary thread when multiple thread-keyed sessions coexist in
|
|
2013
|
+
// one channel (plausible on Slack: bot mentioned in multiple threads). The
|
|
2014
|
+
// user's slash command picker doesn't know about threads; we don't know
|
|
2015
|
+
// which they meant; refusing is safer than guessing.
|
|
2016
|
+
export function resolveLiveSessionForCommand(
|
|
2017
|
+
liveSessions: ReadonlyMap<string, LiveSession>,
|
|
2018
|
+
key: ChannelKey,
|
|
2019
|
+
): ResolveLiveSessionResult {
|
|
2020
|
+
const exact = liveSessions.get(channelKeyId(key))
|
|
2021
|
+
if (exact && !exact.destroyed) return { kind: 'found', session: exact }
|
|
2022
|
+
|
|
2023
|
+
const matches: LiveSession[] = []
|
|
2024
|
+
for (const candidate of liveSessions.values()) {
|
|
2025
|
+
if (candidate.destroyed) continue
|
|
2026
|
+
if (
|
|
2027
|
+
candidate.key.adapter === key.adapter &&
|
|
2028
|
+
candidate.key.workspace === key.workspace &&
|
|
2029
|
+
candidate.key.chat === key.chat
|
|
2030
|
+
) {
|
|
2031
|
+
matches.push(candidate)
|
|
2032
|
+
if (matches.length > 1) {
|
|
2033
|
+
return { kind: 'ambiguous', count: matches.length }
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
if (matches.length === 1) return { kind: 'found', session: matches[0]! }
|
|
2038
|
+
return { kind: 'none' }
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1915
2041
|
function normalizeSendText(text: string | undefined): string | undefined {
|
|
1916
2042
|
if (text === undefined) return undefined
|
|
1917
2043
|
if (text === '') return undefined
|
package/src/cli/role.ts
CHANGED
|
@@ -95,8 +95,13 @@ const listSub = defineCommand({
|
|
|
95
95
|
},
|
|
96
96
|
async run() {
|
|
97
97
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Diagnostic command: route through `loadConfigSyncOrDefaults` (same
|
|
99
|
+
// soft-fail pattern as PR #288's `status`/`doctor` and the follow-up for
|
|
100
|
+
// `model list`) so a broken `typeclaw.json` doesn't crash the very
|
|
101
|
+
// command users reach for to see which roles the agent thinks it has.
|
|
102
|
+
// Defaults have no `roles` block, so the empty-state hint fires next.
|
|
103
|
+
const { loadConfigSyncOrDefaults } = await import('@/config')
|
|
104
|
+
const config = loadConfigSyncOrDefaults(cwd)
|
|
100
105
|
if (!config.roles || Object.keys(config.roles).length === 0) {
|
|
101
106
|
console.log(c.dim('No roles declared. Run `typeclaw role claim` to add one, or edit typeclaw.json by hand.'))
|
|
102
107
|
return
|
package/src/cli/tunnel.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { select, text, isCancel, cancel, log } from '@clack/prompts'
|
|
5
5
|
import { defineCommand } from 'citty'
|
|
6
6
|
|
|
7
|
-
import { loadConfigSync } from '@/config'
|
|
7
|
+
import { loadConfigSync, validateConfig } from '@/config'
|
|
8
8
|
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
9
9
|
import { findAgentDir, isInitialized } from '@/init'
|
|
10
10
|
import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
|
|
@@ -168,6 +168,15 @@ export async function runTunnelAddFlow(
|
|
|
168
168
|
args: AddArgs,
|
|
169
169
|
prompts: TunnelPrompts = defaultPrompts,
|
|
170
170
|
): Promise<LiveResult<TunnelConfig>> {
|
|
171
|
+
// Strict gate before any read: a malformed or schema-invalid `typeclaw.json`
|
|
172
|
+
// would otherwise throw out of the subsequent `loadConfigSync` and surface
|
|
173
|
+
// as an uncaught exception instead of the clean exit-1-with-reason that
|
|
174
|
+
// every other LiveResult consumer expects. Same fence PR #288 documented
|
|
175
|
+
// for the `start`/`restart`/`reload` path: destructive paths route through
|
|
176
|
+
// `validateConfig` so the file's invariants are checked once, up front,
|
|
177
|
+
// and the rest of the flow can lean on them.
|
|
178
|
+
const validation = validateConfig(cwd)
|
|
179
|
+
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
171
180
|
const config = loadConfigSync(cwd)
|
|
172
181
|
if (config.tunnels.some((entry) => entry.name === args.name))
|
|
173
182
|
return { ok: false, reason: `tunnel "${args.name}" already exists` }
|
|
@@ -206,6 +215,9 @@ export async function runTunnelAddFlow(
|
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
|
|
218
|
+
// Same strict gate as `runTunnelAddFlow`. See the comment there for why.
|
|
219
|
+
const validation = validateConfig(cwd)
|
|
220
|
+
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
209
221
|
const config = loadConfigSync(cwd)
|
|
210
222
|
const tunnel = config.tunnels.find((entry) => entry.name === args.name)
|
|
211
223
|
if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
|
package/src/cli/ui.ts
CHANGED
|
@@ -142,6 +142,27 @@ export const SLACK_APP_MANIFEST = {
|
|
|
142
142
|
messages_tab_enabled: true,
|
|
143
143
|
messages_tab_read_only_enabled: false,
|
|
144
144
|
},
|
|
145
|
+
// Slash commands listed here appear in Slack's compose-box picker with
|
|
146
|
+
// their description as a tooltip. `url` is required by Slack's manifest
|
|
147
|
+
// schema even for Socket Mode bots, but is ignored at runtime when the
|
|
148
|
+
// app is in Socket Mode — Slack delivers `slash_commands` envelopes
|
|
149
|
+
// over the same WebSocket as message events. We point it at a
|
|
150
|
+
// deliberately-invalid placeholder (RFC 6761 reserved .invalid TLD)
|
|
151
|
+
// so a misconfigured (non-Socket-Mode) deployment fails fast rather
|
|
152
|
+
// than silently routing real slash invocations to a third-party URL.
|
|
153
|
+
slash_commands: [
|
|
154
|
+
{
|
|
155
|
+
command: '/stop',
|
|
156
|
+
description: 'Abort the current turn in this channel',
|
|
157
|
+
// usage_hint is intentionally omitted. Slack's manifest validator
|
|
158
|
+
// rejects an empty string ("Must be more than 0 characters") but
|
|
159
|
+
// the field is optional, so the cleanest answer is to leave it out
|
|
160
|
+
// rather than invent placeholder text for a command that takes no
|
|
161
|
+
// arguments.
|
|
162
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
163
|
+
should_escape: false,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
145
166
|
},
|
|
146
167
|
oauth_config: {
|
|
147
168
|
scopes: {
|
|
@@ -150,13 +171,16 @@ export const SLACK_APP_MANIFEST = {
|
|
|
150
171
|
// write scopes (chat, files, im/mpim/groups, pins, reactions) let the
|
|
151
172
|
// agent post replies, upload attachments, open DMs, pin messages, and
|
|
152
173
|
// react to messages. `channels:join` lets the bot self-join public
|
|
153
|
-
// channels it's invited to discuss in.
|
|
174
|
+
// channels it's invited to discuss in. `commands` is required for
|
|
175
|
+
// Slack to deliver `slash_commands` envelopes — without it, slash
|
|
176
|
+
// commands registered in `features` would silently fail to route.
|
|
154
177
|
bot: [
|
|
155
178
|
'app_mentions:read',
|
|
156
179
|
'channels:history',
|
|
157
180
|
'channels:join',
|
|
158
181
|
'channels:read',
|
|
159
182
|
'chat:write',
|
|
183
|
+
'commands',
|
|
160
184
|
'emoji:read',
|
|
161
185
|
'files:read',
|
|
162
186
|
'files:write',
|
package/src/config/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
5
|
|
|
6
|
-
import { configSchema,
|
|
6
|
+
import { configSchema, loadConfigSyncOrDefaults, validateConfig } from './config'
|
|
7
7
|
import {
|
|
8
8
|
KNOWN_PROVIDERS,
|
|
9
9
|
listKnownModelRefs,
|
|
@@ -33,8 +33,16 @@ export type ModelProfileEntry = {
|
|
|
33
33
|
|
|
34
34
|
export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
|
|
35
35
|
|
|
36
|
+
// `listModelProfiles` is the read-only path behind `typeclaw model list`, a
|
|
37
|
+
// diagnostic command. It routes through `loadConfigSyncOrDefaults` (same
|
|
38
|
+
// soft-fail pattern as `typeclaw status` / `doctor`, PR #288) so a broken
|
|
39
|
+
// `typeclaw.json` doesn't crash the command users reach for to see what
|
|
40
|
+
// model config the agent thinks it has. Mutation paths (`setProfile`,
|
|
41
|
+
// `addProfile`, `removeProfile`) stay on the strict gate via `validateConfig`
|
|
42
|
+
// in `writeModels`, because writing through a broken-on-disk file would
|
|
43
|
+
// silently land schema-invalid bytes.
|
|
36
44
|
export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
|
|
37
|
-
const models =
|
|
45
|
+
const models = loadConfigSyncOrDefaults(cwd).models
|
|
38
46
|
const out: ModelProfileEntry[] = []
|
|
39
47
|
for (const [profile, refs] of Object.entries(models)) {
|
|
40
48
|
const headRef = refs[0]!
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -394,14 +394,101 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
|
|
|
394
394
|
// `~/.local/bin/claude` shim, which itself dereferences to the versioned
|
|
395
395
|
// binary under `~/.local/share/claude/versions/<ver>/`, so upgrades via
|
|
396
396
|
// `claude update` keep working without re-running this layer.
|
|
397
|
+
// `~/.claude.json` is Claude Code's internal state file (NOT
|
|
398
|
+
// `~/.claude/settings.json`, which is user-facing). On first run with an
|
|
399
|
+
// empty or missing file, `claude` enters a TTY-only theme picker:
|
|
400
|
+
// "Welcome to Claude Code … Choose the text style that looks best with
|
|
401
|
+
// your terminal" with 7 options. The picker is unskippable via CLI
|
|
402
|
+
// flags or env vars (no `--skip-onboarding`, no `--theme=dark`;
|
|
403
|
+
// `IS_DEMO=1` exists but has documented side effects). The single
|
|
404
|
+
// official escape hatch is writing `{"hasCompletedOnboarding": true,
|
|
405
|
+
// "theme": "dark"}` to `~/.claude.json` before the first launch —
|
|
406
|
+
// confirmed by Anthropic in multiple GitHub issues
|
|
407
|
+
// (anthropics/claude-code#4714, #8938, #13827) and the empirical
|
|
408
|
+
// answer used by metabase/metabase's `bin/claude-dangerous`, the
|
|
409
|
+
// `claudeCodeAlDevContainer` feature, and dozens of other Docker
|
|
410
|
+
// integrations.
|
|
411
|
+
//
|
|
412
|
+
// Without the pre-seed, the very first agent-driven `tmux new-session …
|
|
413
|
+
// claude` invocation hangs on the theme picker: the agent's
|
|
414
|
+
// `send-keys "<prompt>" Enter` arrives at the picker, gets interpreted
|
|
415
|
+
// as picker input, and never reaches claude's actual prompt. The
|
|
416
|
+
// `typeclaw-claude-code` skill is structured around a `Stop`-hook
|
|
417
|
+
// sentinel, which never fires while the picker is up, so the polling
|
|
418
|
+
// loop only learns of the hang at the 10-minute wall-clock budget.
|
|
419
|
+
// Pre-seeding here costs ~85 bytes on disk and zero runtime overhead.
|
|
420
|
+
//
|
|
421
|
+
// SCOPE: this seed is NECESSARY but not SUFFICIENT for a fully
|
|
422
|
+
// no-questions-asked first launch. Claude Code also shows two
|
|
423
|
+
// post-seed modal dialogs that this file deliberately does NOT
|
|
424
|
+
// pre-clear:
|
|
425
|
+
// 1. "Detected a custom API key from environment. Do you want to use
|
|
426
|
+
// this API key?" — fires when ANTHROPIC_API_KEY is set. Options
|
|
427
|
+
// `[No (recommended), Yes]`, focus on No, picker does NOT wrap.
|
|
428
|
+
// 2. Workspace trust ("Do you trust the files in this folder?") —
|
|
429
|
+
// fires on every new cwd. Options `[Yes, proceed, No, exit]`,
|
|
430
|
+
// focus on Yes.
|
|
431
|
+
// Both are kept as runtime decisions handled by the
|
|
432
|
+
// `typeclaw-claude-code` skill (see its "Driving the session" section,
|
|
433
|
+
// "Clear startup dialogs" step, which uses dialog-specific keystrokes
|
|
434
|
+
// because the picker doesn't wrap). Pre-seeding
|
|
435
|
+
// `hasTrustDialogAccepted` or `customApiKeyResponses.approved` here
|
|
436
|
+
// would silently widen the trust surface in ways the operator hasn't
|
|
437
|
+
// consented to — the seed's job is strictly cosmetic-wizard removal,
|
|
438
|
+
// not trust/permission preemption.
|
|
439
|
+
//
|
|
440
|
+
// `theme: "dark"` matches typeclaw's default TUI theme so the visual
|
|
441
|
+
// transition between the typeclaw TUI and a tmux-attached claude pane
|
|
442
|
+
// is consistent. Users on light terminals can override by editing
|
|
443
|
+
// `~/.claude.json` (which persists across container restarts only if
|
|
444
|
+
// they mount it; in the default container-ephemeral state it resets
|
|
445
|
+
// to this default on every rebuild, which is fine — `claude` reads
|
|
446
|
+
// the file at startup and the theme has no behavioral impact).
|
|
447
|
+
//
|
|
448
|
+
// `lastOnboardingVersion` is INTENTIONALLY OMITTED. ii-agent and a
|
|
449
|
+
// few other templates ship `lastOnboardingVersion: "1.0.30"`, but
|
|
450
|
+
// that value is version-coupled and goes stale on every Claude Code
|
|
451
|
+
// release. Empirically against Claude Code 2.1.146, the current
|
|
452
|
+
// `hasCompletedOnboarding: true` alone is honored without a version
|
|
453
|
+
// pin. If a future Claude version starts re-triggering the picker
|
|
454
|
+
// when the field is missing, capture `claude --version` output at
|
|
455
|
+
// build time and inject it then — don't hardcode a stale value.
|
|
456
|
+
//
|
|
457
|
+
// `installMethod: "native"` and `numStartups: 1` match the shape
|
|
458
|
+
// Claude Code itself writes after a clean first launch; keeping them
|
|
459
|
+
// makes our seed indistinguishable from a real post-onboarding state,
|
|
460
|
+
// which minimizes the chance of a future "if the file looks like
|
|
461
|
+
// agent-pre-seed, redo onboarding" detection heuristic landing on us.
|
|
462
|
+
//
|
|
463
|
+
// Built via `JSON.stringify` rather than a hand-written string
|
|
464
|
+
// literal so quote/escape bugs surface as TS errors at compile time,
|
|
465
|
+
// not as a corrupt `~/.claude.json` discovered only when the build
|
|
466
|
+
// runs. The `printf '%s\\n' '<JSON>'` shell pattern relies on the
|
|
467
|
+
// JSON containing no single quotes (true by construction — JSON.
|
|
468
|
+
// stringify only emits double quotes); a regression test parses the
|
|
469
|
+
// emitted JSON back to confirm.
|
|
470
|
+
const CLAUDE_CODE_ONBOARDING_SEED = JSON.stringify({
|
|
471
|
+
hasCompletedOnboarding: true,
|
|
472
|
+
theme: 'dark',
|
|
473
|
+
installMethod: 'native',
|
|
474
|
+
numStartups: 1,
|
|
475
|
+
})
|
|
476
|
+
|
|
397
477
|
function renderClaudeCodeInstallLayer(enabled: boolean): string {
|
|
398
478
|
if (!enabled) return ''
|
|
399
479
|
return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
|
|
400
480
|
# typeclaw.json#docker.file.claudeCode. The skill \`typeclaw-claude-code\`
|
|
401
|
-
# documents the auth + usage flow.
|
|
481
|
+
# documents the auth + usage flow. Pre-seed ~/.claude.json so the first
|
|
482
|
+
# launch skips the TTY-only theme picker; see CLAUDE_CODE_ONBOARDING_SEED
|
|
483
|
+
# above for the rationale and what the seed deliberately does NOT cover.
|
|
484
|
+
# The seed write runs LAST in the chain so the final layer state is
|
|
485
|
+
# exactly the seeded config — independent of whether any earlier command
|
|
486
|
+
# (or a future Claude version's \`--version\` smoke test) writes a
|
|
487
|
+
# default \`~/.claude.json\` partway through the layer.
|
|
402
488
|
RUN curl -fsSL https://claude.ai/install.sh | bash \\
|
|
403
489
|
&& ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
|
|
404
|
-
&& claude --version > /dev/null
|
|
490
|
+
&& claude --version > /dev/null \\
|
|
491
|
+
&& printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
|
|
405
492
|
}
|
|
406
493
|
|
|
407
494
|
// Shared-library runtime deps Chrome for Testing needs to launch on amd64
|
package/src/shared/index.ts
CHANGED
package/src/shared/local-time.ts
CHANGED
|
@@ -19,3 +19,20 @@ function formatTimezoneOffset(date: Date): string {
|
|
|
19
19
|
const abs = Math.abs(offsetMinutes)
|
|
20
20
|
return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// IANA timezone name of the process (e.g. `Asia/Seoul`). Reads the resolved
|
|
24
|
+
// zone from Intl, falling back to `UTC` if the runtime cannot resolve one —
|
|
25
|
+
// this should never happen on Bun + tzdata-equipped containers, but the
|
|
26
|
+
// fallback keeps the prompt renderable rather than throwing during session
|
|
27
|
+
// creation. The returned name is what the agent shows the user when asked
|
|
28
|
+
// "what time is it" — pairing the wall clock with a recognizable zone name
|
|
29
|
+
// is what disambiguates "15:31 +09:00" from "15:31 KST" for a non-technical
|
|
30
|
+
// reader.
|
|
31
|
+
export function resolveLocalTimezoneName(): string {
|
|
32
|
+
try {
|
|
33
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
34
|
+
return zone && zone.length > 0 ? zone : 'UTC'
|
|
35
|
+
} catch {
|
|
36
|
+
return 'UTC'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -9,6 +9,12 @@ You can delegate work to Claude Code, Anthropic's official coding agent. The age
|
|
|
9
9
|
|
|
10
10
|
This skill is for the case where Claude Code is the right tool: hard architecture work, multi-file refactors, deep code analysis, a second-opinion read on something you wrote. It is **not** for trivial edits — the round-trip cost (worktree setup + process spawn + auth check + TUI init + at least one full Claude turn) is 15–45 seconds and several thousand tokens of someone else's context window. Do trivial edits yourself.
|
|
11
11
|
|
|
12
|
+
## Run the delegation inside `operator`, not inline
|
|
13
|
+
|
|
14
|
+
Once you've decided Claude Code is the right tool, spawn the bundled `operator` subagent to do the actual driving — don't run the worktree setup, the tmux session, the polling loop, the multi-turn decision loop, and the cleanup inline in your own context. The whole loop typically takes several minutes and produces large amounts of intermediate output (TUI buffer captures, Stop sentinels per turn, JSONL transcript references); running it inline blocks the user from talking to you and burns through your context window before you ever get to the synthesis step. `operator` is write-capable and runs the same loop, then returns a clean final report (what claude produced, what `git diff main..cc-<id>` shows, what you should review). You ship the worktree, the prompt, and the safety constraints to operator; operator ships you back the diff and the summary.
|
|
15
|
+
|
|
16
|
+
Exception: a quick sanity ping (`claude --version` to check the binary exists, `env | grep ANTHROPIC` to check auth). Those are single fast bash calls — do them inline. The "spawn through operator" rule applies to anything that runs `claude` itself as an interactive TUI.
|
|
17
|
+
|
|
12
18
|
## When to delegate to Claude Code
|
|
13
19
|
|
|
14
20
|
Use Claude Code for:
|
|
@@ -79,6 +85,7 @@ Before you spawn `claude` for any real work:
|
|
|
79
85
|
- **`docker.file.claudeCode: true`** in `typeclaw.json`. Verify with `which claude`; if missing, the toggle isn't on. Tell the user to enable it and `typeclaw start --build`.
|
|
80
86
|
- **`docker.file.tmux: true`** (default `true`, but check). Verify with `which tmux`.
|
|
81
87
|
- **Auth set up** — see above. Verify with `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='`.
|
|
88
|
+
- **Onboarding pre-seeded.** The Dockerfile layer writes `~/.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"` so the first `claude` invocation skips the TTY-only theme picker / welcome wizard. **This is necessary but not sufficient** — even with the seed, Claude Code can still land on two other pre-prompt modals: the "Detected a custom API key from environment. Do you want to use this API key?" confirmation (when `ANTHROPIC_API_KEY` is set in env — default focus is **No**, so `Down Enter` is needed to accept) and the workspace trust dialog ("Do you trust the files in this folder?", default focus already on **Yes**, so a bare `Enter` accepts). The "Driving the session" section below clears them as a loop. If `~/.claude.json` is empty or missing entirely (custom mount, manual `rm`, a `CLAUDE_CONFIG_DIR` pointing at a fresh directory), the theme picker also reappears. Self-heal: `printf '%s\n' '{"hasCompletedOnboarding":true,"theme":"dark","installMethod":"native","numStartups":1}' > "$HOME/.claude.json"` before spawning, then retry.
|
|
82
89
|
- **Agent folder is a git repo.** Verify with `git -C /agent rev-parse --is-inside-work-tree`. The worktree model below requires it. If the user's agent folder somehow isn't a repo (rare — `typeclaw init` scaffolds one), tell them to `git init && git add -A && git commit -m "initial"` first.
|
|
83
90
|
- **No uncommitted changes that you care about.** `git -C /agent status --porcelain` should be clean, or you should be willing to set the working tree aside before delegating. The worktree is a separate checkout, so claude can't see your uncommitted changes — meaning claude operates on the last committed state. If the user wants claude to work with in-progress edits, commit them first (even on a WIP branch).
|
|
84
91
|
|
|
@@ -165,11 +172,29 @@ The minimum protocol — translate to your actual tool calls:
|
|
|
165
172
|
1. Create the worktree, write the hook config (above).
|
|
166
173
|
2. `tmux new-session -d -s cc-<id> -c /tmp/cc-<id> claude`.
|
|
167
174
|
3. Wait ~3 seconds for the TUI to initialize.
|
|
168
|
-
4. `
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
4. **Clear startup dialogs (BEFORE sending the task prompt).** Even with `~/.claude.json` pre-seeded, claude can land on one or both pre-prompt modals. Run this as a **loop**, not a one-shot: clearing one dialog can immediately reveal the next, and you must keep polling until claude's actual input prompt is visible (it renders a bottom-of-pane input box with a `╭` / `╰` border).
|
|
176
|
+
|
|
177
|
+
The two known modals, with the exact keystrokes for each (Claude Code's select widget does NOT wrap — pressing `Up` from the first option is a no-op, so the direction must match the dialog's option order):
|
|
178
|
+
- **Custom API key confirmation** — "Detected a custom API key from environment. Do you want to use this API key?" Fires when `ANTHROPIC_API_KEY` is set (exactly typeclaw's auth path). Options are `[No (recommended), Yes]` with focus initialized on **No**. Resolution: `tmux send-keys -t cc-<id> Down Enter` to advance to **Yes** and submit. Sending `Up Enter` would submit the **No** answer, which can persist as a rejection in `customApiKeyResponses.rejected` and break subsequent launches — never do that here.
|
|
179
|
+
|
|
180
|
+
- **Workspace trust** — "Do you trust the files in this folder?" Fires on first launch in any new cwd, so every fresh `/tmp/cc-<id>/` worktree triggers it. Options are `[Yes, proceed, No, exit]` with focus on the first option (**Yes**) by default. Resolution: bare `tmux send-keys -t cc-<id> Enter` — no arrow key needed. Always verify the pane text matches the trust dialog before pressing Enter; a misidentified modal would submit a different default.
|
|
181
|
+
|
|
182
|
+
Loop shape (translate to your tool calls):
|
|
183
|
+
1. Capture the last ~15 lines: `tmux capture-pane -t cc-<id> -p -S -15`.
|
|
184
|
+
2. If the capture contains the API key dialog text → `send-keys Down Enter`, sleep 500ms, goto 1.
|
|
185
|
+
3. If the capture contains the trust dialog text → `send-keys Enter`, sleep 500ms, goto 1.
|
|
186
|
+
4. If the capture shows the input box (`╭` border on a bottom line, no dialog text above it) → ready; exit the loop.
|
|
187
|
+
5. Otherwise sleep 500ms, goto 1. Apply a wall-clock budget of ~10 seconds; if the loop hasn't reached step 4 by then, abort with `/exit` and surface to the user — claude is in a state this skill doesn't model.
|
|
188
|
+
|
|
189
|
+
Do not use a fixed 2-second wait then send the prompt — cold-start and slow-disk cases can deliver a dialog at 2.5s+, and sending the task prompt into a modal corrupts the session.
|
|
190
|
+
|
|
191
|
+
**Safety note**: accepting workspace trust on a fresh `/tmp/cc-<id>/` worktree is the right call **only when its `HEAD` is the intended clean state** — typically the agent folder's last good commit on a branch the user controls. If the user just merged a third-party PR, pulled a remote branch, or checked out an untrusted ref, the worktree carries that content too and "trusting" it gives claude tool access on potentially hostile code. Before auto-accepting trust, sanity-check: if the user hasn't said something equivalent to "delegate this to Claude Code", or if you're not confident the current `HEAD` is one the user authored or reviewed, surface the trust dialog to them instead. Do NOT extend even a legitimate trust acceptance to in-session permission prompts (Bash, Edit, etc.) — those still need per-turn judgment per the multi-turn decision loop below.
|
|
192
|
+
|
|
193
|
+
5. `tmux send-keys -t cc-<id> "<your prompt>" Enter`.
|
|
194
|
+
6. **Poll** for `/tmp/cc-<id>/.done` in a 500ms-cadence loop with a wall-clock budget (default 10 minutes). On every iteration, also check `tmux has-session -t cc-<id>` — if the session died, claude crashed or auth failed.
|
|
195
|
+
7. When `.done` exists: `rm .done`, read `sentinel.json`, examine `last_assistant_message`.
|
|
196
|
+
8. Decide using the multi-turn loop below.
|
|
197
|
+
9. When done: `tmux send-keys -t cc-<id> "/exit" Enter && sleep 1 && tmux kill-session -t cc-<id>`.
|
|
173
198
|
|
|
174
199
|
The full polling implementation, the ANSI-handling rules for `capture-pane` fallbacks, and the "tmux session died unexpectedly" recovery path are in `references/tmux-driving.md`.
|
|
175
200
|
|