typeclaw 0.26.0 → 0.28.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/package.json +1 -1
- package/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/session-origin.ts +9 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +19 -288
- package/src/container/logs.ts +70 -22
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/src/secrets/schema.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { secretFieldSchema, type Secret } from './resolve'
|
|
3
|
+
import { secretFieldSchema } from './resolve'
|
|
5
4
|
|
|
6
5
|
// providers.<id> for api-key credentials: the `key` field is a Secret (string
|
|
7
6
|
// shorthand or `{ value?, env? }` object). resolveSecret turns this into a
|
|
@@ -115,9 +114,7 @@ export const channelsSchema = z
|
|
|
115
114
|
.catchall(z.unknown())
|
|
116
115
|
|
|
117
116
|
// version 2 = providers.* with Secret-typed api-key.key + per-adapter
|
|
118
|
-
// channel field shapes.
|
|
119
|
-
// slots keyed by env-var name). Legacy v1 input is upgraded transparently by
|
|
120
|
-
// parseSecretsFile; the first write persists v2.
|
|
117
|
+
// channel field shapes.
|
|
121
118
|
export const SECRETS_FILE_VERSION = 2
|
|
122
119
|
|
|
123
120
|
export const secretsFileSchema = z.object({
|
|
@@ -140,98 +137,15 @@ export type SecretsFile = z.infer<typeof secretsFileSchema>
|
|
|
140
137
|
|
|
141
138
|
export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
|
|
142
139
|
|
|
143
|
-
// parseSecretsFile
|
|
144
|
-
//
|
|
145
|
-
// 2. The v1 envelope (legacy): { version: 1, llm, channels } where channel
|
|
146
|
-
// slots are keyed by env-var name. Both `llm` and `channels` get
|
|
147
|
-
// reshaped — llm -> providers, env-keyed channel slots -> field-keyed.
|
|
148
|
-
// 3. The pre-envelope flat shape (very legacy): Record<string, AuthCredential>
|
|
149
|
-
// at top level. Treated as { version: 2, providers: <flat>, channels: {} }
|
|
150
|
-
// so existing OAuth users transparently upgrade.
|
|
151
|
-
//
|
|
152
|
-
// Every legacy upgrade produces a v2-shaped SecretsFile in memory; the next
|
|
153
|
-
// write persists v2 to disk. The legacy branches stay forever as a quiet
|
|
154
|
-
// compatibility seam — only the v2 form is documented.
|
|
140
|
+
// parseSecretsFile accepts only the current v2 envelope:
|
|
141
|
+
// { version: 2, providers, channels }.
|
|
155
142
|
export function parseSecretsFile(raw: unknown): ParseSecretsResult {
|
|
156
143
|
const v2 = secretsFileSchema.safeParse(raw)
|
|
157
144
|
if (v2.success) return { ok: true, file: v2.data }
|
|
158
145
|
|
|
159
|
-
const v1 = legacyV1Schema.safeParse(raw)
|
|
160
|
-
if (v1.success) return { ok: true, file: upgradeV1ToV2(v1.data) }
|
|
161
|
-
|
|
162
|
-
const flat = legacyFlatProviderSchema.safeParse(raw)
|
|
163
|
-
if (flat.success) {
|
|
164
|
-
return { ok: true, file: upgradeV1ToV2({ version: 1, llm: flat.data, channels: {} }) }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
146
|
return { ok: false, reason: v2.error.issues.map(formatIssue).join('; ') }
|
|
168
147
|
}
|
|
169
148
|
|
|
170
|
-
// Legacy v1 schema: `llm` (flat string-key) and `channels` (env-var-keyed
|
|
171
|
-
// flat map per adapter). Used only for upgrade reads; never written.
|
|
172
|
-
const legacyV1ApiKeySchema = z.object({
|
|
173
|
-
type: z.literal('api_key'),
|
|
174
|
-
key: z.string().min(1),
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const legacyV1OAuthSchema = z
|
|
178
|
-
.object({
|
|
179
|
-
type: z.literal('oauth'),
|
|
180
|
-
})
|
|
181
|
-
.catchall(z.unknown())
|
|
182
|
-
|
|
183
|
-
const legacyV1CredentialSchema = z.discriminatedUnion('type', [legacyV1ApiKeySchema, legacyV1OAuthSchema])
|
|
184
|
-
|
|
185
|
-
const legacyV1LlmSchema = z.record(z.string(), legacyV1CredentialSchema)
|
|
186
|
-
|
|
187
|
-
const legacyV1ChannelsSchema = z.record(z.string(), z.record(z.string(), z.string()))
|
|
188
|
-
|
|
189
|
-
const legacyV1Schema = z.object({
|
|
190
|
-
$schema: z.string().optional(),
|
|
191
|
-
version: z.literal(1),
|
|
192
|
-
llm: legacyV1LlmSchema.default({}),
|
|
193
|
-
channels: legacyV1ChannelsSchema.default({}),
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
const legacyFlatProviderSchema = z.record(z.string(), legacyV1CredentialSchema)
|
|
197
|
-
|
|
198
|
-
function upgradeV1ToV2(legacy: z.infer<typeof legacyV1Schema>): SecretsFile {
|
|
199
|
-
const providers: Providers = {}
|
|
200
|
-
for (const [providerId, cred] of Object.entries(legacy.llm)) {
|
|
201
|
-
if (cred.type === 'api_key') {
|
|
202
|
-
providers[providerId] = { type: 'api_key', key: { value: cred.key } }
|
|
203
|
-
} else {
|
|
204
|
-
providers[providerId] = cred
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const channels: Channels = {}
|
|
209
|
-
for (const [adapterId, envKeyedSlot] of Object.entries(legacy.channels)) {
|
|
210
|
-
const upgradedSlot: Record<string, Secret> = {}
|
|
211
|
-
for (const [envKey, value] of Object.entries(envKeyedSlot)) {
|
|
212
|
-
const mapping = CHANNEL_ENV_TO_FIELD[envKey]
|
|
213
|
-
if (mapping && mapping.adapterId === adapterId) {
|
|
214
|
-
upgradedSlot[mapping.fieldName] = { value }
|
|
215
|
-
} else {
|
|
216
|
-
// Unknown env-var-name key on a known adapter, or an adapter we don't
|
|
217
|
-
// recognise: pass through verbatim under the original key. Better to
|
|
218
|
-
// preserve user data than drop it; the catchall on channelsSchema
|
|
219
|
-
// makes this safe.
|
|
220
|
-
upgradedSlot[envKey] = { value }
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
channels[adapterId] = upgradedSlot
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const result: SecretsFile = {
|
|
227
|
-
version: SECRETS_FILE_VERSION,
|
|
228
|
-
providers,
|
|
229
|
-
channels,
|
|
230
|
-
}
|
|
231
|
-
if (legacy.$schema !== undefined) result.$schema = legacy.$schema
|
|
232
|
-
return result
|
|
233
|
-
}
|
|
234
|
-
|
|
235
149
|
function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
|
|
236
150
|
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
|
|
237
151
|
return `${path}: ${issue.message}`
|
package/src/secrets/storage.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
import lockfile from 'proper-lockfile'
|
|
10
10
|
|
|
11
11
|
import { providerKeyDefaultEnv } from './defaults'
|
|
12
|
-
import { migrateLegacyAuthJson } from './migrate'
|
|
13
12
|
import { resolveSecret, type Secret } from './resolve'
|
|
14
13
|
import {
|
|
15
14
|
type Channels,
|
|
@@ -375,7 +374,6 @@ export class SecretsBackend implements AuthStorageBackend {
|
|
|
375
374
|
}
|
|
376
375
|
|
|
377
376
|
export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
|
|
378
|
-
migrateLegacyAuthJson(dirname(secretsPath))
|
|
379
377
|
return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
|
|
380
378
|
}
|
|
381
379
|
|
package/src/server/index.ts
CHANGED
|
@@ -1181,10 +1181,6 @@ async function handleCronList(
|
|
|
1181
1181
|
// jobs from a newer registry.
|
|
1182
1182
|
const snapshot = pluginRuntime?.get()
|
|
1183
1183
|
const loadResult = await loadCron(agentDir, {
|
|
1184
|
-
// Read-only path: do not rewrite cron.json or commit the
|
|
1185
|
-
// migration just because the user (or the agent) asked to see
|
|
1186
|
-
// the schedule. Boot/reload still own the persistent migration.
|
|
1187
|
-
persistMigrations: false,
|
|
1188
1184
|
...(snapshot !== undefined ? { subagents: snapshot.subagents } : {}),
|
|
1189
1185
|
})
|
|
1190
1186
|
if (!loadResult.ok) {
|
|
@@ -20,7 +20,7 @@ Before you pick an action, classify the inbound. Skipping this step is how a PR
|
|
|
20
20
|
|
|
21
21
|
1. **Is this a PR, and do I have an unresolved blocking obligation on it?** On any `pr:N` inbound, before anything else, check whether you owe this PR a verdict you have not yet landed. Check **both** signals below — checking only formal review state misses the very failure this gate exists to catch, because a prior block may never have become formal state:
|
|
22
22
|
- **Formal review state.** Run the step-1 re-review query in the PR review flow (`gh api --paginate --slurp /repos/owner/repo/pulls/<N>/reviews --jq '…'` filtered to `{CHANGES_REQUESTED, APPROVED}`). If your latest **blocking decision** is `CHANGES_REQUESTED`, you have a live sticky block.
|
|
23
|
-
- **Flat-comment blockers you authored.** A prior "request changes" may have been posted as a plain PR/issue comment instead of a formal review — in which case **no `CHANGES_REQUESTED` row exists** and the query above returns empty even though you blocked the PR in prose. So also scan your own recent comments (`gh api /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'`) for one that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction. For routing, a blocking comment you wrote is as binding as a formal `CHANGES_REQUESTED`.
|
|
23
|
+
- **Flat-comment blockers you authored.** A prior "request changes" may have been posted as a plain PR/issue comment instead of a formal review — in which case **no `CHANGES_REQUESTED` row exists** and the query above returns empty even though you blocked the PR in prose. So also scan your own recent comments (`gh api /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'`) for one that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction. For routing, a blocking comment you wrote is as binding as a formal `CHANGES_REQUESTED`. **A courtesy acknowledgement is not a retraction.** A reply you posted like "nice, that closes the hole" / "thanks, looks good" / "✅" does **not** supersede or retract a blocker you raised — it is a chat ack, not a verdict, and it carries no review state. The blocker stays binding until you land a **formal** `APPROVE`/`REQUEST_CHANGES` (or dismiss your prior review). So when an earlier "✅ thanks" of yours is the only thing between your blocker and the author's address-comment, the blocker is **still live** and this inbound is a re-review — do not let your own ack downgrade it.
|
|
24
24
|
|
|
25
25
|
If **either** signal shows an unresolved blocker you raised, this inbound is a **re-review** — go to the **PR review flow** regardless of how it is phrased. An author commenting "fixed both issues" / "addressed your feedback" / "pushed a fix" is a re-review trigger, **not** a thread-resolve trigger. A re-review is closed by re-deciding the verdict and landing a **formal** review via `POST /pulls/<N>/reviews`: `APPROVE` clears a sticky `CHANGES_REQUESTED`; a comment or a flat reply clears neither a formal block nor a flat-comment blocker — it just strands the verdict again, which is the original bug.
|
|
26
26
|
|
|
@@ -201,6 +201,8 @@ A review you posted leaves inline comment threads open on the PR. When one of **
|
|
|
201
201
|
|
|
202
202
|
**The base principle: whoever opened the thread closes it.** Resolve only threads whose root comment **you** authored. Never resolve a human reviewer's thread on your behalf — that erases their open question. The thread you can resolve is the one you started; the inbound that brings you here is a **review-thread reply on `pr:N` with `thread` set**, replying inside a thread you opened.
|
|
203
203
|
|
|
204
|
+
> **Thread cleanup is not the same as discharging a PR-level block.** Resolving an inline thread closes that one thread; it carries **no** review state and does **not** clear a PR-level blocking obligation (a sticky `CHANGES_REQUESTED`, or a flat blocker you authored on the PR conversation). Those are two separate duties. If triage #1 found a live PR-level block, that inbound is a **re-review** and the PR review flow wins over this section — you owe a formal `APPROVE`/`REQUEST_CHANGES`, and resolving threads (or a chat ✅) must **not** be your final response. Reach this section only for a thread-scoped reply on a PR where you owe no PR-level verdict (triage #1 came back clean). When in doubt, re-run triage #1 first.
|
|
205
|
+
|
|
204
206
|
### When a thread counts as addressed
|
|
205
207
|
|
|
206
208
|
Do not resolve on a bare "done" claim. A reply that says "fixed" is a prompt to check, not proof. Before resolving, **verify the fix at the PR's current head SHA**:
|
|
@@ -54,7 +54,7 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
|
|
|
54
54
|
|
|
55
55
|
> **Top-level keys not in this table are not "ignored unknowns" anymore** — they are reserved for **plugin config blocks**. The schema's `catchall(z.unknown())` preserves them, and the plugin loader hands each block to its owning plugin's `configSchema` for validation. The bundled memory plugin owns `memory` at the top level — see the `typeclaw-memory` skill for that block's semantics. Do not write a top-level key unless you know which plugin owns it.
|
|
56
56
|
|
|
57
|
-
Within the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are migrated
|
|
57
|
+
Within the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are no longer migrated — use `docker.file` and `git.ignore` directly (the legacy keys are silently ignored). Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
|
|
58
58
|
|
|
59
59
|
A scaffolded `typeclaw.json` looks like:
|
|
60
60
|
|
|
@@ -436,7 +436,7 @@ The toggle-driven apt install benefits from BuildKit `--mount=type=cache` on `/v
|
|
|
436
436
|
|
|
437
437
|
`typeclaw start` rewrites the agent folder's `.gitignore` from a template baked into the typeclaw CLI on **every** invocation, then auto-commits it when the agent folder is a git repo and the file changed. The template protects two categories: truly-ignored paths (`secrets.json`, `.env`, `.env.local`, `auth.json`, `node_modules/`, `workspace/`, `mounts/`, `channels/`, `Dockerfile`, `.DS_Store`) and system-managed runtime state (`sessions/`, `memory/`) that TypeClaw, not the agent, commits on its own schedule. Editing `.gitignore` by hand is temporary; the next `typeclaw start` overwrites it.
|
|
438
438
|
|
|
439
|
-
The `git.ignore.append` field
|
|
439
|
+
The `git.ignore.append` field is the supported escape hatch for additional local ignore patterns. It is an array of strings, each treated as a single `.gitignore` line. The CLI splices them into the autogenerated `.gitignore` before TypeClaw's protected rules, prefixed with a `# Custom entries from typeclaw.json#git.ignore.append.` comment.
|
|
440
440
|
|
|
441
441
|
### Field
|
|
442
442
|
|
|
@@ -487,19 +487,17 @@ channels/
|
|
|
487
487
|
|
|
488
488
|
## Legacy migration
|
|
489
489
|
|
|
490
|
-
|
|
490
|
+
The only migration step that still runs is dropping a seeded `channels.github.eventAllowlist` field — if present, it is removed and the file is rewritten with a descriptive commit subject.
|
|
491
491
|
|
|
492
|
-
|
|
492
|
+
All other legacy shapes are no longer migrated:
|
|
493
493
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
3. If the file already has a `docker` or `git` block AND the legacy key, the new shape wins — the legacy duplicate is dropped silently. The new shape would have shadowed the legacy at parse time anyway.
|
|
494
|
+
- **Top-level `dockerfile` and `gitignore` keys** are silently ignored on parse. Use `docker.file` and `git.ignore` directly. If a file still carries these keys, update it by hand.
|
|
495
|
+
- **`channels.<adapter>.allow[]`** is silently ignored on parse and NOT translated to `roles.member.match[]`. Define `roles.member.match[]` directly.
|
|
497
496
|
|
|
498
497
|
What this means for you:
|
|
499
498
|
|
|
500
|
-
- **Do not write top-level `dockerfile` or `gitignore` keys** when editing `typeclaw.json`. They
|
|
499
|
+
- **Do not write top-level `dockerfile` or `gitignore` keys** when editing `typeclaw.json`. They are ignored; the intended fields are `docker.file` and `git.ignore`.
|
|
501
500
|
- **Old documentation or examples that still mention `typeclaw.json#dockerfile.append` are stale.** The current path is `typeclaw.json#docker.file.append`. Same for `git.ignore.append`.
|
|
502
|
-
- **An auto-commit may appear** the next time `typeclaw start` runs against a freshly-migrated agent folder. The diff is mechanical (top-level rename → nested) — surface it to the user as a one-time migration, not a behavior change.
|
|
503
501
|
|
|
504
502
|
## Plugin config blocks
|
|
505
503
|
|
|
@@ -550,7 +548,7 @@ Do **not** edit `typeclaw.json` to a model the registry doesn't know, even if th
|
|
|
550
548
|
- `slack-bot: { botToken: <Secret>, appToken: <Secret> }`
|
|
551
549
|
- `telegram-bot: { token: <Secret> }`
|
|
552
550
|
|
|
553
|
-
(
|
|
551
|
+
(Only the `v2` envelope is accepted. Pre-v2 shapes and `auth.json` are no longer auto-upgraded — they are rejected with an error. `auth.json` stays gitignored as a safety net for old folders, but it is not read.)
|
|
554
552
|
|
|
555
553
|
- **`./.env`** (env-var overrides): plain `KEY=value` lines, loaded by Docker via `--env-file` at container start. When set, an env var **wins** over the file value (see resolution rules below). Useful for CI, transient rotations, or any tooling outside typeclaw that reads from the environment. The canonical env-var names per provider:
|
|
556
554
|
- `OPENAI_API_KEY` — for any `openai/...` model.
|
|
@@ -630,7 +628,7 @@ Never echo, log, or commit values from `secrets.json` or `.env`. Both are gitign
|
|
|
630
628
|
- If `alias` is set: array of strings, each non-empty after trimming surrounding whitespace
|
|
631
629
|
- If `channels.<adapter>.engagement.trigger` is set: array of `"mention"`, `"reply"`, `"dm"` (any subset, including empty)
|
|
632
630
|
- If `channels.<adapter>.engagement.stickiness` is set: either the literal `"off"` or `{ "perReply": { "window": <int 1..86400000> } }`
|
|
633
|
-
- `channels.<adapter>.allow` (legacy) is silently
|
|
631
|
+
- `channels.<adapter>.allow` (legacy) is silently ignored on parse and NOT translated to `roles.member.match`. Define `roles.member.match[]` directly. See the `typeclaw-permissions` skill.
|
|
634
632
|
- If `portForward` is set: `allow` is either `"*"` or an array of integers (1–65535); `deny`, if present, is an array of integers and **only valid when `allow` is `"*"`** (the schema rejects `deny` paired with a number-array `allow`)
|
|
635
633
|
- If `docker.file.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
|
|
636
634
|
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `python`, `cjkFonts`, `cloudflared`, `claudeCode`, and `codexCli` are boolean only
|
|
@@ -81,7 +81,7 @@ Things the DSL rejects (the parser emits actionable errors at boot, but you shou
|
|
|
81
81
|
- `slack:*/*` — `*/*` is redundant; use `slack:*` for "any Slack chat".
|
|
82
82
|
- `slack:*/C0ABCDE` — workspace-less chat ID is impossible; pick a workspace.
|
|
83
83
|
- `slack:T0123/*` — workspace-only is enough; drop the trailing `/*`.
|
|
84
|
-
- `team:T0123`, `guild:G123`, `tg:42` — these are legacy prefixes
|
|
84
|
+
- `team:T0123`, `guild:G123`, `tg:42` — these are legacy prefixes that are no longer supported. The parser rejects them with a hint to use the canonical form: `slack:T0123`, `discord:G123`, `telegram:42`.
|
|
85
85
|
- `autor:U_ME` — typo of `author:`. The parser will suggest the fix at boot.
|
|
86
86
|
|
|
87
87
|
## Permission strings you will see
|
package/src/tui/index.ts
CHANGED
|
@@ -50,13 +50,21 @@ export type TuiOptions = {
|
|
|
50
50
|
onVersionMismatch?: (info: VersionMismatch) => void
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// Outcome of a single `run()` cycle.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
53
|
+
// Outcome of a single `run()` cycle.
|
|
54
|
+
// - 'detach': idle Esc — return to the session-viewer list. Closing the WS
|
|
55
|
+
// ends the server-side AgentSession (accepted; the list re-shows it as a
|
|
56
|
+
// read-only transcript).
|
|
57
|
+
// - 'exit': deliberate /quit or Ctrl+C — terminate the client.
|
|
58
|
+
// - 'lostConnection': WS closed AFTER the handshake without a deliberate
|
|
59
|
+
// quit/detach — exactly the self-restart case, and the only one where a
|
|
60
|
+
// fresh connect can recover the session.
|
|
61
|
+
// - 'connectFailed': pre-handshake connect/handshake error.
|
|
62
|
+
// The CLI reconnect loop spins only on 'lostConnection'.
|
|
63
|
+
export type TuiRunResult =
|
|
64
|
+
| { reason: 'detach' }
|
|
65
|
+
| { reason: 'exit'; exitCode: number }
|
|
66
|
+
| { reason: 'lostConnection' }
|
|
67
|
+
| { reason: 'connectFailed' }
|
|
60
68
|
|
|
61
69
|
export function createTui({
|
|
62
70
|
url,
|
|
@@ -68,7 +76,7 @@ export function createTui({
|
|
|
68
76
|
expectedVersion,
|
|
69
77
|
onVersionMismatch,
|
|
70
78
|
}: TuiOptions) {
|
|
71
|
-
async function run(): Promise<
|
|
79
|
+
async function run(): Promise<TuiRunResult> {
|
|
72
80
|
const terminal = createTerminal()
|
|
73
81
|
const tui = new TUI(terminal)
|
|
74
82
|
const displayUrl = redactUrl(url)
|
|
@@ -78,13 +86,19 @@ export function createTui({
|
|
|
78
86
|
tui.start()
|
|
79
87
|
tui.requestRender()
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
// Pre-handshake failures resolve 'connectFailed' (not throw): the standalone
|
|
90
|
+
// CLI injects exit=process.exit so exit(1) ends the process and the return is
|
|
91
|
+
// moot; the viewer injects a no-op exit so run() resolves cleanly and the
|
|
92
|
+
// caller maps connectFailed into an error result instead of an uncaught reject.
|
|
93
|
+
const maybeClient = await createClient(url).catch((err) => {
|
|
82
94
|
status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
|
|
83
95
|
tui.requestRender()
|
|
84
96
|
tui.stop()
|
|
85
97
|
exit(1)
|
|
86
|
-
|
|
98
|
+
return null
|
|
87
99
|
})
|
|
100
|
+
if (maybeClient === null) return { reason: 'connectFailed' }
|
|
101
|
+
const client = maybeClient
|
|
88
102
|
|
|
89
103
|
const handshake = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
|
|
90
104
|
status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
|
|
@@ -92,10 +106,10 @@ export function createTui({
|
|
|
92
106
|
client.close()
|
|
93
107
|
tui.stop()
|
|
94
108
|
exit(1)
|
|
95
|
-
|
|
109
|
+
return null
|
|
96
110
|
})
|
|
111
|
+
if (handshake === null) return { reason: 'connectFailed' }
|
|
97
112
|
|
|
98
|
-
let userInitiatedShutdown = false
|
|
99
113
|
const { sessionId, serverVersion } = handshake
|
|
100
114
|
status.setText(colors.dim(`session: ${sessionId}`))
|
|
101
115
|
tui.requestRender()
|
|
@@ -231,12 +245,23 @@ export function createTui({
|
|
|
231
245
|
}
|
|
232
246
|
})
|
|
233
247
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
248
|
+
let settleOutcome: ((result: TuiRunResult) => void) | null = null
|
|
249
|
+
const outcome = new Promise<TuiRunResult>((resolve) => {
|
|
250
|
+
settleOutcome = resolve
|
|
251
|
+
})
|
|
252
|
+
const settle = (result: TuiRunResult): void => {
|
|
253
|
+
if (settleOutcome === null) return
|
|
254
|
+
const fn = settleOutcome
|
|
255
|
+
settleOutcome = null
|
|
256
|
+
fn(result)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
client.onClose(() => {
|
|
260
|
+
appendHistory(new Text(colors.dim('disconnected'), 0, 0))
|
|
261
|
+
tui.requestRender()
|
|
262
|
+
// A user-initiated detach/exit already closed the WS deliberately and
|
|
263
|
+
// settled the outcome; onClose then fires but must not override it.
|
|
264
|
+
settle({ reason: 'lostConnection' })
|
|
240
265
|
})
|
|
241
266
|
|
|
242
267
|
function send(text: string): Promise<void> {
|
|
@@ -249,7 +274,7 @@ export function createTui({
|
|
|
249
274
|
|
|
250
275
|
function runTuiCommand(command: TuiCommandName): boolean {
|
|
251
276
|
if (command === 'quit') {
|
|
252
|
-
|
|
277
|
+
exitWith(0)
|
|
253
278
|
return true
|
|
254
279
|
}
|
|
255
280
|
if (command === 'reload') {
|
|
@@ -266,29 +291,44 @@ export function createTui({
|
|
|
266
291
|
return true
|
|
267
292
|
}
|
|
268
293
|
|
|
269
|
-
// Esc
|
|
270
|
-
//
|
|
294
|
+
// Esc means "abort the in-flight reply" while a turn is generating, and
|
|
295
|
+
// "detach back to the session list" when idle. The Editor does not bind
|
|
296
|
+
// Esc, so a top-level listener intercepts it without fighting the editor.
|
|
271
297
|
tui.addInputListener((data) => {
|
|
272
|
-
if (matchesKey(data, Key.escape)
|
|
298
|
+
if (!matchesKey(data, Key.escape)) return undefined
|
|
299
|
+
if (replyInFlight) {
|
|
273
300
|
client.send({ type: 'abort' })
|
|
274
301
|
return { consume: true }
|
|
275
302
|
}
|
|
276
|
-
|
|
303
|
+
detach()
|
|
304
|
+
return { consume: true }
|
|
277
305
|
})
|
|
278
306
|
|
|
279
|
-
|
|
280
|
-
|
|
307
|
+
// Settle BEFORE closing the client: client.close() fires onClose, which
|
|
308
|
+
// settles 'lostConnection'. settle() is idempotent, so the first call wins —
|
|
309
|
+
// settling the deliberate outcome first keeps the later onClose a no-op.
|
|
310
|
+
const teardown = (): void => {
|
|
281
311
|
tui.stop()
|
|
282
312
|
client.close()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const exitWith = (code: number): void => {
|
|
316
|
+
settle({ reason: 'exit', exitCode: code })
|
|
317
|
+
teardown()
|
|
283
318
|
exit(code)
|
|
284
319
|
}
|
|
285
320
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
321
|
+
const detach = (): void => {
|
|
322
|
+
settle({ reason: 'detach' })
|
|
323
|
+
teardown()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Ctrl+C exits the client. In raw mode the kernel does NOT generate SIGINT,
|
|
327
|
+
// so we intercept the \x03 byte ourselves; the Editor would otherwise
|
|
328
|
+
// swallow it. teardown() restores raw-mode/cursor/echo before we settle.
|
|
289
329
|
tui.addInputListener((data) => {
|
|
290
330
|
if (matchesKey(data, Key.ctrl('c'))) {
|
|
291
|
-
|
|
331
|
+
exitWith(0)
|
|
292
332
|
return { consume: true }
|
|
293
333
|
}
|
|
294
334
|
return undefined
|
|
@@ -330,15 +370,15 @@ export function createTui({
|
|
|
330
370
|
const command = parseBareTuiCommand(initialPrompt)
|
|
331
371
|
if (command !== null) {
|
|
332
372
|
runTuiCommand(command)
|
|
333
|
-
if (command === 'quit') return {
|
|
373
|
+
if (command === 'quit') return { reason: 'exit', exitCode: 0 }
|
|
334
374
|
} else {
|
|
335
375
|
await send(initialPrompt)
|
|
336
376
|
}
|
|
337
377
|
}
|
|
338
378
|
|
|
339
|
-
const
|
|
379
|
+
const result = await outcome
|
|
340
380
|
tui.stop()
|
|
341
|
-
return
|
|
381
|
+
return result
|
|
342
382
|
}
|
|
343
383
|
|
|
344
384
|
return { run }
|
package/typeclaw.schema.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export function normalizeRef(ref: string): string {
|
|
2
|
-
const trimmed = ref.trim()
|
|
3
|
-
// New classifiers store bare Slack file ids; legacy persisted refs (and
|
|
4
|
-
// anything still hitting the lookup path from older contextBuffer state)
|
|
5
|
-
// may carry the old prompt-visible `id=Fxxxx` prefix. Strip it here so
|
|
6
|
-
// both attachment-fetching tools route the same ref through the adapter
|
|
7
|
-
// callback — without this, `channel_fetch_attachment` would silently
|
|
8
|
-
// succeed on a legacy ref while `look_at_channel_attachment` would fail.
|
|
9
|
-
if (trimmed.startsWith('id=')) return trimmed.slice(3)
|
|
10
|
-
return trimmed
|
|
11
|
-
}
|