typeclaw 0.33.0 → 0.34.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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/github-cli-auth/git-askpass.ts +65 -0
- package/src/bundled-plugins/github-cli-auth/git-command.ts +492 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +97 -36
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/config/providers.ts +62 -0
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +1 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +2 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +1 -1
- package/src/secrets/schema.ts +25 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +167 -3
package/auth.schema.json
CHANGED
|
@@ -276,6 +276,72 @@
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
},
|
|
279
|
+
"line": {
|
|
280
|
+
"type": "object",
|
|
281
|
+
"properties": {
|
|
282
|
+
"currentAccount": {
|
|
283
|
+
"anyOf": [
|
|
284
|
+
{
|
|
285
|
+
"type": "string"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"type": "null"
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
"accounts": {
|
|
293
|
+
"type": "object",
|
|
294
|
+
"propertyNames": {
|
|
295
|
+
"type": "string"
|
|
296
|
+
},
|
|
297
|
+
"additionalProperties": {
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {
|
|
300
|
+
"account_id": {
|
|
301
|
+
"type": "string"
|
|
302
|
+
},
|
|
303
|
+
"auth_token": {
|
|
304
|
+
"type": "string"
|
|
305
|
+
},
|
|
306
|
+
"certificate": {
|
|
307
|
+
"type": "string"
|
|
308
|
+
},
|
|
309
|
+
"device": {
|
|
310
|
+
"type": "string",
|
|
311
|
+
"enum": [
|
|
312
|
+
"DESKTOPWIN",
|
|
313
|
+
"DESKTOPMAC",
|
|
314
|
+
"ANDROID",
|
|
315
|
+
"ANDROIDSECONDARY",
|
|
316
|
+
"IOS",
|
|
317
|
+
"IOSIPAD"
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
"display_name": {
|
|
321
|
+
"type": "string"
|
|
322
|
+
},
|
|
323
|
+
"created_at": {
|
|
324
|
+
"type": "string"
|
|
325
|
+
},
|
|
326
|
+
"updated_at": {
|
|
327
|
+
"type": "string"
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
"required": [
|
|
331
|
+
"account_id",
|
|
332
|
+
"auth_token",
|
|
333
|
+
"device",
|
|
334
|
+
"created_at",
|
|
335
|
+
"updated_at"
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"required": [
|
|
341
|
+
"currentAccount",
|
|
342
|
+
"accounts"
|
|
343
|
+
]
|
|
344
|
+
},
|
|
279
345
|
"kakaotalk": {
|
|
280
346
|
"type": "object",
|
|
281
347
|
"properties": {
|
package/cron.schema.json
CHANGED
|
@@ -22,6 +22,19 @@
|
|
|
22
22
|
"type": "string",
|
|
23
23
|
"minLength": 1
|
|
24
24
|
},
|
|
25
|
+
"at": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1
|
|
28
|
+
},
|
|
29
|
+
"until": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1
|
|
32
|
+
},
|
|
33
|
+
"count": {
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"exclusiveMinimum": 0,
|
|
36
|
+
"maximum": 9007199254740991
|
|
37
|
+
},
|
|
25
38
|
"enabled": {
|
|
26
39
|
"default": true,
|
|
27
40
|
"type": "boolean"
|
|
@@ -49,7 +62,6 @@
|
|
|
49
62
|
},
|
|
50
63
|
"required": [
|
|
51
64
|
"id",
|
|
52
|
-
"schedule",
|
|
53
65
|
"kind",
|
|
54
66
|
"prompt"
|
|
55
67
|
]
|
|
@@ -66,6 +78,19 @@
|
|
|
66
78
|
"type": "string",
|
|
67
79
|
"minLength": 1
|
|
68
80
|
},
|
|
81
|
+
"at": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"minLength": 1
|
|
84
|
+
},
|
|
85
|
+
"until": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"minLength": 1
|
|
88
|
+
},
|
|
89
|
+
"count": {
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"exclusiveMinimum": 0,
|
|
92
|
+
"maximum": 9007199254740991
|
|
93
|
+
},
|
|
69
94
|
"enabled": {
|
|
70
95
|
"default": true,
|
|
71
96
|
"type": "boolean"
|
|
@@ -92,7 +117,6 @@
|
|
|
92
117
|
},
|
|
93
118
|
"required": [
|
|
94
119
|
"id",
|
|
95
|
-
"schedule",
|
|
96
120
|
"kind",
|
|
97
121
|
"command"
|
|
98
122
|
]
|
package/package.json
CHANGED
package/secrets.schema.json
CHANGED
|
@@ -276,6 +276,72 @@
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
},
|
|
279
|
+
"line": {
|
|
280
|
+
"type": "object",
|
|
281
|
+
"properties": {
|
|
282
|
+
"currentAccount": {
|
|
283
|
+
"anyOf": [
|
|
284
|
+
{
|
|
285
|
+
"type": "string"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"type": "null"
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
"accounts": {
|
|
293
|
+
"type": "object",
|
|
294
|
+
"propertyNames": {
|
|
295
|
+
"type": "string"
|
|
296
|
+
},
|
|
297
|
+
"additionalProperties": {
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {
|
|
300
|
+
"account_id": {
|
|
301
|
+
"type": "string"
|
|
302
|
+
},
|
|
303
|
+
"auth_token": {
|
|
304
|
+
"type": "string"
|
|
305
|
+
},
|
|
306
|
+
"certificate": {
|
|
307
|
+
"type": "string"
|
|
308
|
+
},
|
|
309
|
+
"device": {
|
|
310
|
+
"type": "string",
|
|
311
|
+
"enum": [
|
|
312
|
+
"DESKTOPWIN",
|
|
313
|
+
"DESKTOPMAC",
|
|
314
|
+
"ANDROID",
|
|
315
|
+
"ANDROIDSECONDARY",
|
|
316
|
+
"IOS",
|
|
317
|
+
"IOSIPAD"
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
"display_name": {
|
|
321
|
+
"type": "string"
|
|
322
|
+
},
|
|
323
|
+
"created_at": {
|
|
324
|
+
"type": "string"
|
|
325
|
+
},
|
|
326
|
+
"updated_at": {
|
|
327
|
+
"type": "string"
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
"required": [
|
|
331
|
+
"account_id",
|
|
332
|
+
"auth_token",
|
|
333
|
+
"device",
|
|
334
|
+
"created_at",
|
|
335
|
+
"updated_at"
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"required": [
|
|
341
|
+
"currentAccount",
|
|
342
|
+
"accounts"
|
|
343
|
+
]
|
|
344
|
+
},
|
|
279
345
|
"kakaotalk": {
|
|
280
346
|
"type": "object",
|
|
281
347
|
"properties": {
|
package/src/agent/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
wrapAgentToolWithBudget,
|
|
68
68
|
wrapToolDefinitionWithBudget,
|
|
69
69
|
} from './tool-result-budget'
|
|
70
|
+
import { createChannelDisengageTool } from './tools/channel-disengage'
|
|
70
71
|
import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
|
|
71
72
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
72
73
|
import { createChannelReactTool } from './tools/channel-react'
|
|
@@ -621,9 +622,11 @@ export function formatRestartNoticeOriginating(restartedAt: string): string {
|
|
|
621
622
|
}
|
|
622
623
|
|
|
623
624
|
// Builds the channel tool subset: channel_send (always when a router is
|
|
624
|
-
// available), plus
|
|
625
|
-
//
|
|
626
|
-
//
|
|
625
|
+
// available), plus the origin-bound channel tools when the session origin is
|
|
626
|
+
// a channel — channel_reply, channel_history, channel_react,
|
|
627
|
+
// channel_fetch_attachment, look_at_channel_attachment, channel_disengage, and
|
|
628
|
+
// (when sessionId is known) skip_response. Those rely on origin-bound
|
|
629
|
+
// addressing or per-session turn state. Extracted from
|
|
627
630
|
// createSessionWithDispose so composition can be unit-tested without
|
|
628
631
|
// going through createAgentSession / auth.
|
|
629
632
|
//
|
|
@@ -683,6 +686,7 @@ export function buildChannelTools(
|
|
|
683
686
|
}),
|
|
684
687
|
)
|
|
685
688
|
tools.push(createChannelLookAtTool(channelRouter, channelOrigin))
|
|
689
|
+
tools.push(createChannelDisengageTool({ router: channelRouter, origin: channelOrigin }))
|
|
686
690
|
if (sessionId !== undefined) {
|
|
687
691
|
tools.push(createSkipResponseTool({ router: channelRouter, sessionId }))
|
|
688
692
|
}
|
|
@@ -156,6 +156,7 @@ const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
|
|
|
156
156
|
supportsReactions: false,
|
|
157
157
|
supportsAttachments: true,
|
|
158
158
|
},
|
|
159
|
+
line: { displayName: 'LINE', mentionMode: 'alias', supportsReactions: false, supportsAttachments: false },
|
|
159
160
|
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias', supportsReactions: false, supportsAttachments: true },
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -425,6 +426,22 @@ function renderChannelOrigin(
|
|
|
425
426
|
' have no reason worth recording. Any other visible text without a',
|
|
426
427
|
' channel tool call is blocked.',
|
|
427
428
|
'',
|
|
429
|
+
'Both of the above silence only the CURRENT turn. To stop being pulled',
|
|
430
|
+
'back into FUTURE turns, use the engagement tool below.',
|
|
431
|
+
'',
|
|
432
|
+
'- **`channel_disengage()`** — drop "mid-conversation" stickiness for this',
|
|
433
|
+
' conversation. After you reply to someone, their next message re-engages',
|
|
434
|
+
' you without an @mention, and that is renewed on every reply — so in a',
|
|
435
|
+
' busy group you can get stuck answering turn after turn even after being',
|
|
436
|
+
' told to stop. Call this when a human or peer bot asks you to be quiet /',
|
|
437
|
+
' stop replying, or when you notice you are in a redundant loop. After',
|
|
438
|
+
' disengaging you only re-engage when explicitly addressed again (mention,',
|
|
439
|
+
' reply, or DM). It sends no message and does not affect other channels.',
|
|
440
|
+
' ORDER MATTERS: if you want to ack ("ok, backing off") before going quiet,',
|
|
441
|
+
" send that `channel_reply` FIRST, THEN call `channel_disengage` — it's the",
|
|
442
|
+
' natural terminal action for the turn. Pair it with `skip_response` when',
|
|
443
|
+
' you also want to stay silent this turn.',
|
|
444
|
+
'',
|
|
428
445
|
'**Every user-facing sentence goes through `channel_reply`.** Narrating in',
|
|
429
446
|
'plain text — "bumping to 16x now", "let me check that" — does NOT reach the',
|
|
430
447
|
'user; it is invisible. If you want the user to see it, it is a',
|
|
@@ -15,6 +15,7 @@ export type CompletionReminderArgs = {
|
|
|
15
15
|
ok: boolean
|
|
16
16
|
durationMs: number
|
|
17
17
|
error?: string
|
|
18
|
+
hasRecoverableOutput?: boolean
|
|
18
19
|
channel?: boolean
|
|
19
20
|
adapter?: string
|
|
20
21
|
}
|
|
@@ -55,10 +56,14 @@ export function renderSubagentCompletionReminder(args: CompletionReminderArgs):
|
|
|
55
56
|
)
|
|
56
57
|
}
|
|
57
58
|
const err = args.error ?? 'unknown error'
|
|
59
|
+
const recoveryHint =
|
|
60
|
+
args.hasRecoverableOutput === true
|
|
61
|
+
? `It produced output before failing — call subagent_output to recover it instead of redoing the work. `
|
|
62
|
+
: `Use subagent_output to inspect. `
|
|
58
63
|
return (
|
|
59
64
|
`<system-reminder>\n` +
|
|
60
65
|
`Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
|
|
61
|
-
|
|
66
|
+
`${recoveryHint}If this work was tracked in your todo list, ` +
|
|
62
67
|
`keep the item pending (or add a recovery item) via todo_write so it is not ` +
|
|
63
68
|
`dropped.${channelTail}\n` +
|
|
64
69
|
`</system-reminder>`
|
|
@@ -88,6 +93,12 @@ export type SubagentCompletedPayload = {
|
|
|
88
93
|
ok: boolean
|
|
89
94
|
durationMs: number
|
|
90
95
|
error?: string
|
|
96
|
+
// A failed subagent can still carry a recoverable result (e.g. a researcher
|
|
97
|
+
// that produced its `<report>` then timed out). The boolean — not the content
|
|
98
|
+
// — rides the broadcast so the reminder can tell the parent to fetch it via
|
|
99
|
+
// subagent_output; the body stays in the registry retrieval path, off the
|
|
100
|
+
// broadcast bus, to keep large/sensitive output out of every subscriber.
|
|
101
|
+
hasRecoverableOutput?: boolean
|
|
91
102
|
// Present when the parent was a channel session. Lets the router fall back
|
|
92
103
|
// to the live successor session for the same channel key when the parent
|
|
93
104
|
// rolled over (SESSION_FRESHNESS_TTL_MS) or was idle-evicted while the
|
|
@@ -109,6 +120,7 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
|
|
|
109
120
|
ok?: unknown
|
|
110
121
|
durationMs?: unknown
|
|
111
122
|
error?: unknown
|
|
123
|
+
hasRecoverableOutput?: unknown
|
|
112
124
|
channelKey?: unknown
|
|
113
125
|
}
|
|
114
126
|
if (p.kind !== 'subagent.completed') return null
|
|
@@ -121,6 +133,7 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
|
|
|
121
133
|
ok: p.ok === true,
|
|
122
134
|
durationMs: typeof p.durationMs === 'number' ? p.durationMs : 0,
|
|
123
135
|
...(typeof p.error === 'string' ? { error: p.error } : {}),
|
|
136
|
+
...(p.hasRecoverableOutput === true ? { hasRecoverableOutput: true } : {}),
|
|
124
137
|
...(channelKey !== null ? { channelKey } : {}),
|
|
125
138
|
}
|
|
126
139
|
}
|
|
@@ -71,12 +71,14 @@ function collectPendingReminders(drain: SubagentBackgroundDrain, delivered: Set<
|
|
|
71
71
|
if (child.status === 'running') continue
|
|
72
72
|
if (delivered.has(child.taskId)) continue
|
|
73
73
|
const completion = child.completion
|
|
74
|
+
const hasRecoverableOutput = child.status !== 'completed' && completion?.finalMessage !== undefined
|
|
74
75
|
const text = renderSubagentCompletionReminder({
|
|
75
76
|
subagent: child.subagentName,
|
|
76
77
|
taskId: child.taskId,
|
|
77
78
|
ok: child.status === 'completed',
|
|
78
79
|
durationMs: completion?.durationMs ?? 0,
|
|
79
80
|
...(completion?.error !== undefined ? { error: completion.error } : {}),
|
|
81
|
+
...(hasRecoverableOutput ? { hasRecoverableOutput: true } : {}),
|
|
80
82
|
})
|
|
81
83
|
pending.push({ taskId: child.taskId, text })
|
|
82
84
|
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -360,7 +360,7 @@ export type SubagentHandle = {
|
|
|
360
360
|
|
|
361
361
|
export type StartSubagentResult = {
|
|
362
362
|
handle: Promise<SubagentHandle>
|
|
363
|
-
completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string }>
|
|
363
|
+
completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string }>
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
export type StartSubagentOptions = InvokeSubagentOptions & {
|
|
@@ -427,7 +427,8 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
|
|
|
427
427
|
})
|
|
428
428
|
|
|
429
429
|
const timeoutMs = options.registry[name]?.timeoutMs
|
|
430
|
-
const completion =
|
|
430
|
+
const completion =
|
|
431
|
+
timeoutMs === undefined ? work : raceSubagentCompletion(work, name, options.taskId, timeoutMs, () => finalMessage)
|
|
431
432
|
|
|
432
433
|
void completion.then(() => {
|
|
433
434
|
if (timeoutMs !== undefined) void abortSession?.()
|
|
@@ -436,20 +437,33 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
|
|
|
436
437
|
return { handle, completion }
|
|
437
438
|
}
|
|
438
439
|
|
|
439
|
-
type SubagentCompletion = { ok: true; finalMessage?: string } | { ok: false; error: string }
|
|
440
|
+
type SubagentCompletion = { ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string }
|
|
440
441
|
|
|
442
|
+
// `getFinalMessage` is read INSIDE the timeout callback, not at race-construction
|
|
443
|
+
// time, so the timed-out result carries whatever the subagent had captured by the
|
|
444
|
+
// moment the timer fired (e.g. a researcher's `<report>` block emitted just before
|
|
445
|
+
// the kill). JS is single-threaded, so this read is torn-free; it preserves only
|
|
446
|
+
// what an assistant `message_end` already committed — a result still mid-stream
|
|
447
|
+
// when the timer fires cannot be recovered. The outcome stays `ok: false`: a
|
|
448
|
+
// timeout is a lifecycle failure, and `finalMessage` here is recovery data for
|
|
449
|
+
// the parent to inspect/re-persist, not proof the subagent honored its contract.
|
|
441
450
|
function raceSubagentCompletion(
|
|
442
451
|
work: Promise<SubagentCompletion>,
|
|
443
452
|
name: string,
|
|
444
453
|
taskId: string,
|
|
445
454
|
timeoutMs: number,
|
|
455
|
+
getFinalMessage: () => string | undefined,
|
|
446
456
|
): Promise<SubagentCompletion> {
|
|
447
457
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
448
458
|
const timeout = new Promise<SubagentCompletion>((resolve) => {
|
|
449
|
-
timer = setTimeout(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
459
|
+
timer = setTimeout(() => {
|
|
460
|
+
const finalMessage = getFinalMessage()
|
|
461
|
+
resolve({
|
|
462
|
+
ok: false,
|
|
463
|
+
error: new SubagentTimeoutError(name, taskId, timeoutMs).message,
|
|
464
|
+
...(finalMessage !== undefined ? { finalMessage } : {}),
|
|
465
|
+
})
|
|
466
|
+
}, timeoutMs)
|
|
453
467
|
})
|
|
454
468
|
return Promise.race([work, timeout]).finally(() => {
|
|
455
469
|
if (timer !== null) clearTimeout(timer)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
5
|
+
import type { AdapterId } from '@/channels/schema'
|
|
6
|
+
|
|
7
|
+
export type ChannelDisengageOrigin = {
|
|
8
|
+
adapter: AdapterId
|
|
9
|
+
workspace: string
|
|
10
|
+
chat: string
|
|
11
|
+
thread: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type CreateChannelDisengageToolOptions = {
|
|
15
|
+
router: ChannelRouter
|
|
16
|
+
origin: ChannelDisengageOrigin
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ChannelDisengageDetails = {
|
|
20
|
+
ok: boolean
|
|
21
|
+
cleared: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `channel_disengage` drops the "we're mid-conversation" sticky credits for the
|
|
25
|
+
// current channel/thread. Stickiness force-engages the bot on a participant's
|
|
26
|
+
// follow-up even without an @mention, and every reply re-grants a fresh credit —
|
|
27
|
+
// so in a busy group the bot keeps answering turn after turn, even after a human
|
|
28
|
+
// or peer bot asks it to stop. Calling this returns the bot to strict
|
|
29
|
+
// mention/reply/dm engagement for this conversation until someone addresses it
|
|
30
|
+
// again. Use it as a clean exit when you've been told to back off or when you
|
|
31
|
+
// notice you're stuck in a redundant back-and-forth.
|
|
32
|
+
export function createChannelDisengageTool({ router, origin }: CreateChannelDisengageToolOptions) {
|
|
33
|
+
return defineTool({
|
|
34
|
+
name: 'channel_disengage',
|
|
35
|
+
label: 'Channel Disengage',
|
|
36
|
+
description:
|
|
37
|
+
'Stop auto-engaging on follow-up messages in THIS channel/thread. While engaged you ' +
|
|
38
|
+
"keep replying to a participant's next message without an @mention, and that " +
|
|
39
|
+
'engagement is renewed every time you reply — so in a group you can get stuck ' +
|
|
40
|
+
'answering turn after turn even after someone tells you to stop. Call this when a ' +
|
|
41
|
+
'human or peer bot asks you to be quiet / stop replying, or when you realize you are ' +
|
|
42
|
+
'in a redundant loop. After disengaging, you only re-engage in this conversation when ' +
|
|
43
|
+
'explicitly addressed again (mention, reply, or DM). This does not send any message ' +
|
|
44
|
+
'and does not affect other channels. Pair it with skip_response when you also want to ' +
|
|
45
|
+
'stay silent on the current turn.',
|
|
46
|
+
parameters: Type.Object({}),
|
|
47
|
+
|
|
48
|
+
async execute() {
|
|
49
|
+
const result = router.clearSticky({
|
|
50
|
+
adapter: origin.adapter,
|
|
51
|
+
workspace: origin.workspace,
|
|
52
|
+
chat: origin.chat,
|
|
53
|
+
thread: origin.thread,
|
|
54
|
+
})
|
|
55
|
+
const details: ChannelDisengageDetails = { ok: true, cleared: result.cleared }
|
|
56
|
+
const summary =
|
|
57
|
+
result.cleared > 0
|
|
58
|
+
? `Disengaged from this conversation (${result.cleared} active engagement${result.cleared === 1 ? '' : 's'} dropped). You will only re-engage here when explicitly addressed again.`
|
|
59
|
+
: 'You were not auto-engaged in this conversation; nothing to drop. You already only engage here when explicitly addressed.'
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text' as const, text: summary }],
|
|
62
|
+
details,
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Shared logger surface for the channel_* agent tools.
|
|
2
2
|
//
|
|
3
|
-
// Until now, channel_send / channel_reply /
|
|
4
|
-
//
|
|
3
|
+
// Until now, the channel tools (channel_send / channel_reply /
|
|
4
|
+
// channel_history / channel_fetch_attachment / channel_disengage / ...)
|
|
5
|
+
// swallowed every failure into the model-visible
|
|
5
6
|
// tool result and emitted nothing to the container's stdout/stderr. That
|
|
6
7
|
// made operator-side debugging blind: a Slack send that 403'd, a
|
|
7
8
|
// `thread-scope-requires-thread-session` denial, or a Discord attachment
|
|
@@ -29,7 +29,7 @@ export type SpawnSubagentToolDetails =
|
|
|
29
29
|
taskId: string
|
|
30
30
|
sessionId: string | undefined
|
|
31
31
|
}
|
|
32
|
-
| { ok: false; error: string }
|
|
32
|
+
| { ok: false; error: string; finalMessage?: string }
|
|
33
33
|
|
|
34
34
|
export type CreateSpawnSubagentToolOptions = {
|
|
35
35
|
registry: SubagentRegistry
|
|
@@ -167,6 +167,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
167
167
|
const durationMs = now() - startedAt
|
|
168
168
|
liveRegistry.recordCompletion(taskId, completionToFinalShape(c, durationMs))
|
|
169
169
|
if (stream && background) {
|
|
170
|
+
const hasRecoverableOutput = !c.ok && c.finalMessage !== undefined
|
|
170
171
|
stream.publish({
|
|
171
172
|
target: { kind: 'broadcast' },
|
|
172
173
|
payload: {
|
|
@@ -177,6 +178,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
177
178
|
ok: c.ok,
|
|
178
179
|
durationMs,
|
|
179
180
|
...(c.ok ? {} : { error: c.error }),
|
|
181
|
+
...(hasRecoverableOutput ? { hasRecoverableOutput: true } : {}),
|
|
180
182
|
...(channelKey !== undefined ? { channelKey } : {}),
|
|
181
183
|
},
|
|
182
184
|
})
|
|
@@ -205,9 +207,22 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
205
207
|
const result = await completion
|
|
206
208
|
const durationMs = now() - startedAt
|
|
207
209
|
if (!result.ok) {
|
|
208
|
-
const details: SpawnSubagentToolDetails = {
|
|
210
|
+
const details: SpawnSubagentToolDetails = {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: result.error,
|
|
213
|
+
...(result.finalMessage !== undefined ? { finalMessage: result.finalMessage } : {}),
|
|
214
|
+
}
|
|
215
|
+
const recovered =
|
|
216
|
+
result.finalMessage !== undefined
|
|
217
|
+
? ` It produced output before failing; recover it below instead of redoing the work:\n\n${result.finalMessage}`
|
|
218
|
+
: ''
|
|
209
219
|
return {
|
|
210
|
-
content: [
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
type: 'text' as const,
|
|
223
|
+
text: `${subagentName} failed after ${durationMs}ms: ${result.error}.${recovered}`,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
211
226
|
details,
|
|
212
227
|
}
|
|
213
228
|
}
|
|
@@ -312,13 +327,18 @@ function hasPermissionForSubagent(
|
|
|
312
327
|
}
|
|
313
328
|
|
|
314
329
|
function completionToFinalShape(
|
|
315
|
-
c: { ok: true; finalMessage?: string } | { ok: false; error: string },
|
|
330
|
+
c: { ok: true; finalMessage?: string } | { ok: false; error: string; finalMessage?: string },
|
|
316
331
|
durationMs: number,
|
|
317
332
|
): SubagentCompletion {
|
|
318
333
|
if (c.ok) {
|
|
319
334
|
return { ok: true, durationMs, ...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}) }
|
|
320
335
|
}
|
|
321
|
-
return {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: c.error,
|
|
339
|
+
durationMs,
|
|
340
|
+
...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}),
|
|
341
|
+
}
|
|
322
342
|
}
|
|
323
343
|
|
|
324
344
|
type ToolReturn = {
|
|
@@ -37,6 +37,7 @@ export type SubagentOutputToolDetails =
|
|
|
37
37
|
subagent: string
|
|
38
38
|
durationMs: number
|
|
39
39
|
error: string
|
|
40
|
+
finalMessage?: string
|
|
40
41
|
}
|
|
41
42
|
| { ok: false; error: string }
|
|
42
43
|
|
|
@@ -137,6 +138,7 @@ function renderSnapshot(snap: StatusSnapshot): ToolReturn {
|
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
const error = snap.completion?.error ?? 'unknown error'
|
|
141
|
+
const finalMessage = snap.completion?.finalMessage
|
|
140
142
|
const details: SubagentOutputToolDetails = {
|
|
141
143
|
ok: true,
|
|
142
144
|
status: 'failed',
|
|
@@ -144,9 +146,19 @@ function renderSnapshot(snap: StatusSnapshot): ToolReturn {
|
|
|
144
146
|
subagent: snap.subagentName,
|
|
145
147
|
durationMs: snap.completion?.durationMs ?? snap.elapsedMs,
|
|
146
148
|
error,
|
|
149
|
+
...(finalMessage !== undefined ? { finalMessage } : {}),
|
|
147
150
|
}
|
|
151
|
+
const recovered =
|
|
152
|
+
finalMessage !== undefined
|
|
153
|
+
? ` It produced output before failing; recover it below instead of redoing the work:\n\n${finalMessage}`
|
|
154
|
+
: ''
|
|
148
155
|
return {
|
|
149
|
-
content: [
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: 'text' as const,
|
|
159
|
+
text: `${snap.subagentName} failed after ${details.durationMs}ms: ${error}.${recovered}`,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
150
162
|
details,
|
|
151
163
|
}
|
|
152
164
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { chmod, mkdir, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// A GIT_ASKPASS helper git invokes for username/password prompts. The token
|
|
6
|
+
// rides in TYPECLAW_GIT_TOKEN (env, via the bash env overlay), NEVER in argv or
|
|
7
|
+
// git config — so it cannot leak through process listings, logs, or .git/config.
|
|
8
|
+
// The script contents are constant and secret-free; only the env value is secret.
|
|
9
|
+
//
|
|
10
|
+
// Host-scoped: git's prompt is `Username for 'https://github.com': ` etc. We
|
|
11
|
+
// answer ONLY when the prompt names github.com; for any other host (e.g. one an
|
|
12
|
+
// `insteadOf`/`pushurl` rewrite redirected to) we exit non-zero WITHOUT printing
|
|
13
|
+
// the token, so a redirect can never exfiltrate it. The analyzer already blocks
|
|
14
|
+
// the known redirect vectors; this is defense-in-depth at the credential edge.
|
|
15
|
+
// The host match is on \`//github.com/\` and \`//github.com'\` (git wraps the URL
|
|
16
|
+
// in quotes: \`Password for 'https://github.com': \`) so it cannot be fooled by
|
|
17
|
+
// \`evil-github.com\` or \`github.com.evil/\`.
|
|
18
|
+
const ASKPASS_SCRIPT = `#!/bin/sh
|
|
19
|
+
case "$1" in
|
|
20
|
+
*//github.com/*|*//github.com\\'*) : ;;
|
|
21
|
+
*) exit 1 ;;
|
|
22
|
+
esac
|
|
23
|
+
case "$1" in
|
|
24
|
+
*Username*) printf '%s\\n' 'x-access-token' ;;
|
|
25
|
+
*) printf '%s\\n' "$TYPECLAW_GIT_TOKEN" ;;
|
|
26
|
+
esac
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
// /usr is --ro-bind mounted into the per-tool bwrap sandbox (src/sandbox/build.ts),
|
|
30
|
+
// so a helper here is readable by sandboxed bash; the per-session /tmp bind is not
|
|
31
|
+
// a stable path. TYPECLAW_GIT_ASKPASS_PATH overrides it for tests/CI, which
|
|
32
|
+
// cannot write under /usr.
|
|
33
|
+
const DEFAULT_ASKPASS_PATH = '/usr/local/bin/typeclaw-git-askpass'
|
|
34
|
+
|
|
35
|
+
function defaultPath(): string {
|
|
36
|
+
const override = process.env.TYPECLAW_GIT_ASKPASS_PATH
|
|
37
|
+
return override !== undefined && override !== '' ? override : DEFAULT_ASKPASS_PATH
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let ensurePromise: Promise<string> | null = null
|
|
41
|
+
|
|
42
|
+
export function resetGitAskPassHelperForTests(): void {
|
|
43
|
+
ensurePromise = null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Writes the helper once per process (idempotent, race-safe via the shared
|
|
47
|
+
// promise) and returns its absolute path. The temp name is unpredictable and
|
|
48
|
+
// opened with `wx` (exclusive create, fails on an existing file/symlink) so a
|
|
49
|
+
// planted symlink cannot redirect the write; then atomically renamed so a
|
|
50
|
+
// concurrent reader never sees a partial file.
|
|
51
|
+
export function ensureGitAskPassHelper(path: string = defaultPath()): Promise<string> {
|
|
52
|
+
if (ensurePromise !== null) return ensurePromise
|
|
53
|
+
ensurePromise = (async () => {
|
|
54
|
+
await mkdir(dirname(path), { recursive: true })
|
|
55
|
+
const tmp = join(dirname(path), `.typeclaw-git-askpass.${randomBytes(8).toString('hex')}.tmp`)
|
|
56
|
+
await writeFile(tmp, ASKPASS_SCRIPT, { mode: 0o755, flag: 'wx' })
|
|
57
|
+
await chmod(tmp, 0o755)
|
|
58
|
+
await rename(tmp, path)
|
|
59
|
+
return path
|
|
60
|
+
})().catch((err) => {
|
|
61
|
+
ensurePromise = null
|
|
62
|
+
throw err
|
|
63
|
+
})
|
|
64
|
+
return ensurePromise
|
|
65
|
+
}
|