typeclaw 0.33.0 → 0.34.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/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/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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { LineClient as RealLineClient, LineCredentialManager, type LineLoginResult } from 'agent-messenger/line'
|
|
4
|
+
|
|
5
|
+
import { SecretsLineCredentialStore } from '@/secrets/line-store'
|
|
6
|
+
|
|
7
|
+
export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
8
|
+
|
|
9
|
+
export type LineLoginCallbacks = {
|
|
10
|
+
onQRUrl?: (url: string) => void
|
|
11
|
+
onPincode: (pin: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// QR is the default because a LINE account may have no usable e-mail/password
|
|
15
|
+
// (social-login accounts), and QR only adds bootstrap-time UX — the persisted
|
|
16
|
+
// credential (auth_token + certificate) is identical regardless of method.
|
|
17
|
+
export type LineLoginInput =
|
|
18
|
+
| {
|
|
19
|
+
method: 'qr'
|
|
20
|
+
agentDir: string
|
|
21
|
+
callbacks: LineLoginCallbacks
|
|
22
|
+
client?: LineLoginClient
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
method: 'email'
|
|
26
|
+
email: string
|
|
27
|
+
password: string
|
|
28
|
+
agentDir: string
|
|
29
|
+
callbacks: LineLoginCallbacks
|
|
30
|
+
client?: LineLoginClient
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Structural subset of the upstream LineClient the bootstrap drives. Declared
|
|
34
|
+
// here so tests can inject a fake without standing up the real LOCO client.
|
|
35
|
+
export type LineLoginClient = {
|
|
36
|
+
loginWithQR(options: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void }): Promise<LineLoginResult>
|
|
37
|
+
loginWithEmail(options: {
|
|
38
|
+
email: string
|
|
39
|
+
password: string
|
|
40
|
+
onPincode: (pin: string) => void
|
|
41
|
+
}): Promise<LineLoginResult>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function lineSecretsPath(agentDir: string): string {
|
|
45
|
+
return join(agentDir, 'secrets.json')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runLineBootstrap(input: LineLoginInput): Promise<LineBootstrapStatus> {
|
|
49
|
+
try {
|
|
50
|
+
const store = new SecretsLineCredentialStore({ mode: 'host', secretsPath: lineSecretsPath(input.agentDir) })
|
|
51
|
+
// The LINE SDK persists the minted auth_token + certificate by calling
|
|
52
|
+
// setAccount() on whatever credential manager the client was built with.
|
|
53
|
+
// Wiring our secrets.json-backed store in here means a successful login
|
|
54
|
+
// writes straight to secrets.json#channels.line — no second copy in
|
|
55
|
+
// ~/.config/agent-messenger to keep in sync.
|
|
56
|
+
const client = input.client ?? buildLineClient(store)
|
|
57
|
+
|
|
58
|
+
const result =
|
|
59
|
+
input.method === 'qr'
|
|
60
|
+
? await client.loginWithQR({
|
|
61
|
+
onQRUrl: (url) => input.callbacks.onQRUrl?.(url),
|
|
62
|
+
onPincode: input.callbacks.onPincode,
|
|
63
|
+
})
|
|
64
|
+
: await client.loginWithEmail({
|
|
65
|
+
email: input.email,
|
|
66
|
+
password: input.password,
|
|
67
|
+
onPincode: input.callbacks.onPincode,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!result.authenticated || result.account_id === undefined) {
|
|
71
|
+
const reason = result.message ?? result.error ?? 'LINE login did not authenticate'
|
|
72
|
+
return { ok: false, reason }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The SDK persists the account by calling setAccount() on the credential
|
|
76
|
+
// manager as a side effect of login. We can't assume it did: read the
|
|
77
|
+
// record back and require an auth_token before declaring success, so a
|
|
78
|
+
// login that authenticated but failed to persist surfaces as an error
|
|
79
|
+
// instead of a green "added" with an empty secrets.json#channels.line.
|
|
80
|
+
const persisted = await store.getAccount(result.account_id)
|
|
81
|
+
if (persisted === null || persisted.auth_token === '') {
|
|
82
|
+
return { ok: false, reason: 'LINE login authenticated but did not persist credentials' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await store.setCurrentAccount(result.account_id)
|
|
86
|
+
return { ok: true }
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildLineClient(store: SecretsLineCredentialStore): LineLoginClient {
|
|
93
|
+
// The upstream LineClient constructor takes a LineCredentialManager. Our
|
|
94
|
+
// store implements the same setAccount/getAccount surface the login path
|
|
95
|
+
// calls, so it stands in as the credential manager via a structural cast.
|
|
96
|
+
const credManager = store as unknown as LineCredentialManager
|
|
97
|
+
return new RealLineClient(credManager) as unknown as LineLoginClient
|
|
98
|
+
}
|
package/src/init/models-dev.ts
CHANGED
|
@@ -9,6 +9,7 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
|
|
|
9
9
|
'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
|
|
10
10
|
xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
|
|
11
11
|
minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
|
|
12
|
+
deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
// When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
|
|
@@ -163,6 +164,7 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
|
|
|
163
164
|
'zai-coding': 'https://docs.z.ai/devpack/tool/claude#api-key',
|
|
164
165
|
xai: 'https://console.x.ai',
|
|
165
166
|
minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
|
|
167
|
+
deepseek: 'https://platform.deepseek.com/api_keys',
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
// MiniMax sells the same `minimax` provider under two billing surfaces that
|
package/src/inspect/label.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// error messages with typo suggestions; a single big regex would only ever
|
|
17
17
|
// say "didn't match".
|
|
18
18
|
|
|
19
|
-
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
|
|
19
|
+
export const PLATFORMS = ['slack', 'discord', 'telegram', 'line', 'kakao', 'github'] as const
|
|
20
20
|
export type Platform = (typeof PLATFORMS)[number]
|
|
21
21
|
|
|
22
22
|
const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
|
|
@@ -35,9 +35,10 @@ export type MatchRule =
|
|
|
35
35
|
workspace?: string
|
|
36
36
|
chat?: string
|
|
37
37
|
// Buckets for DM-style scopes. `slack:dm/*`, `discord:dm/*`,
|
|
38
|
-
// `kakao:dm/*`, `kakao:group/*`, `kakao:open
|
|
39
|
-
// (no workspace,
|
|
40
|
-
|
|
38
|
+
// `kakao:dm/*`, `kakao:group/*`, `kakao:open/*`, `line:dm/*`,
|
|
39
|
+
// `line:group/*`, `line:square/*` produce `bucket` only (no workspace,
|
|
40
|
+
// no chat).
|
|
41
|
+
bucket?: 'dm' | 'group' | 'open' | 'square'
|
|
41
42
|
author?: string
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,7 +51,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
|
|
|
50
51
|
// this to `author:` only, the JSON schema would reject typos with a generic
|
|
51
52
|
// "did not match pattern" error and the user would lose the actionable hint.
|
|
52
53
|
export const MATCH_RULE_REGEX_SOURCE =
|
|
53
|
-
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
|
+
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|line|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
55
|
|
|
55
56
|
export function parseMatchRule(input: string): ParseMatchRuleResult {
|
|
56
57
|
if (input !== input.trim() || input.length === 0) {
|
|
@@ -164,15 +165,16 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
if (head === 'dm' || head === 'group' || head === 'open') {
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
if (head === 'dm' || head === 'group' || head === 'open' || head === 'square') {
|
|
169
|
+
const bucketError = invalidBucketForPlatform(head, platform)
|
|
170
|
+
if (bucketError !== null) {
|
|
171
|
+
return { ok: false, error: bucketError }
|
|
170
172
|
}
|
|
171
173
|
if (tail === '') {
|
|
172
174
|
return { ok: false, error: `bucket '${platform}:${head}/' requires '*' or a chat id` }
|
|
173
175
|
}
|
|
174
176
|
if (tail === '*') {
|
|
175
|
-
return { ok: true, value: buildChannelRule(platform, { bucket: head
|
|
177
|
+
return { ok: true, value: buildChannelRule(platform, { bucket: head, author }) }
|
|
176
178
|
}
|
|
177
179
|
// `slack:dm/<id>` — keep the bucket plus the specific chat. We omit a
|
|
178
180
|
// separate workspace field; DM IDs are globally unique within a
|
|
@@ -180,7 +182,7 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
180
182
|
return {
|
|
181
183
|
ok: true,
|
|
182
184
|
value: buildChannelRule(platform, {
|
|
183
|
-
bucket: head
|
|
185
|
+
bucket: head,
|
|
184
186
|
chat: tail,
|
|
185
187
|
author,
|
|
186
188
|
}),
|
|
@@ -199,12 +201,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
// No slash: `slack:T0123` or `kakao:dm` (bare bucket — error).
|
|
202
|
-
if (rest === 'dm' || rest === 'group' || rest === 'open') {
|
|
204
|
+
if (rest === 'dm' || rest === 'group' || rest === 'open' || rest === 'square') {
|
|
203
205
|
return { ok: false, error: `bucket '${platform}:${rest}' requires a chat id or '*'` }
|
|
204
206
|
}
|
|
205
207
|
return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
// `dm` is universal. `group`/`open` are KakaoTalk buckets; `group`/`square`
|
|
211
|
+
// are LINE buckets. Reject a bucket on a platform whose workspace shapes don't
|
|
212
|
+
// produce it, so a typo'd rule fails loudly instead of silently never matching.
|
|
213
|
+
function invalidBucketForPlatform(bucket: 'dm' | 'group' | 'open' | 'square', platform: Platform): string | null {
|
|
214
|
+
if (bucket === 'dm') return null
|
|
215
|
+
if (bucket === 'open') {
|
|
216
|
+
return platform === 'kakao' ? null : `bucket 'open' is only valid for kakao`
|
|
217
|
+
}
|
|
218
|
+
if (bucket === 'square') {
|
|
219
|
+
return platform === 'line' ? null : `bucket 'square' is only valid for line`
|
|
220
|
+
}
|
|
221
|
+
return platform === 'kakao' || platform === 'line' ? null : `bucket 'group' is only valid for kakao or line`
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
|
|
209
225
|
const [owner, repo, ...chatParts] = rest.split('/')
|
|
210
226
|
if (owner === undefined || owner === '' || repo === undefined || repo === '') {
|
|
@@ -230,7 +246,7 @@ function buildChannelRule(
|
|
|
230
246
|
parts: {
|
|
231
247
|
workspace?: string
|
|
232
248
|
chat?: string
|
|
233
|
-
bucket?: 'dm' | 'group' | 'open'
|
|
249
|
+
bucket?: 'dm' | 'group' | 'open' | 'square'
|
|
234
250
|
author?: string
|
|
235
251
|
},
|
|
236
252
|
): MatchRule {
|
|
@@ -7,6 +7,7 @@ const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
|
|
|
7
7
|
'discord-bot': 'discord',
|
|
8
8
|
github: 'github',
|
|
9
9
|
'telegram-bot': 'telegram',
|
|
10
|
+
line: 'line',
|
|
10
11
|
kakaotalk: 'kakao',
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -63,10 +64,16 @@ function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: M
|
|
|
63
64
|
// the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
|
|
64
65
|
// the workspace prefix itself.
|
|
65
66
|
function matchesBucket(
|
|
66
|
-
bucket: 'dm' | 'group' | 'open',
|
|
67
|
+
bucket: 'dm' | 'group' | 'open' | 'square',
|
|
67
68
|
origin: Extract<MatchableOrigin, { kind: 'channel' }>,
|
|
68
69
|
): boolean {
|
|
69
70
|
const platform = ADAPTER_TO_PLATFORM[origin.adapter]
|
|
71
|
+
if (platform === 'line') {
|
|
72
|
+
if (bucket === 'dm') return origin.workspace === '@line-dm'
|
|
73
|
+
if (bucket === 'group') return origin.workspace === '@line-group'
|
|
74
|
+
if (bucket === 'square') return origin.workspace === '@line-square'
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
70
77
|
if (platform === 'kakao') {
|
|
71
78
|
if (bucket === 'dm') return origin.workspace === '@kakao-dm'
|
|
72
79
|
if (bucket === 'group') return origin.workspace === '@kakao-group'
|
|
@@ -25,11 +25,15 @@ export type PartialChannelOrigin = {
|
|
|
25
25
|
authorId: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const ADAPTER_TO_PLATFORM: Record<
|
|
28
|
+
const ADAPTER_TO_PLATFORM: Record<
|
|
29
|
+
ChannelKey['adapter'],
|
|
30
|
+
'slack' | 'discord' | 'telegram' | 'kakao' | 'line' | 'github'
|
|
31
|
+
> = {
|
|
29
32
|
'slack-bot': 'slack',
|
|
30
33
|
'discord-bot': 'discord',
|
|
31
34
|
github: 'github',
|
|
32
35
|
'telegram-bot': 'telegram',
|
|
36
|
+
line: 'line',
|
|
33
37
|
kakaotalk: 'kakao',
|
|
34
38
|
}
|
|
35
39
|
|
package/src/run/index.ts
CHANGED
|
@@ -30,9 +30,11 @@ import {
|
|
|
30
30
|
import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
|
|
31
31
|
import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
|
|
32
32
|
import {
|
|
33
|
+
type CountStore,
|
|
33
34
|
type CronConsumer,
|
|
34
35
|
type CronJob,
|
|
35
36
|
type CronFile,
|
|
37
|
+
createCountStore,
|
|
36
38
|
createCronConsumer,
|
|
37
39
|
createCronReloadable,
|
|
38
40
|
createScheduler,
|
|
@@ -77,7 +79,12 @@ type BunServer = ReturnType<Server['start']>
|
|
|
77
79
|
export type TuiFactory = (options: TuiOptions) => { run: () => Promise<unknown> }
|
|
78
80
|
|
|
79
81
|
export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
|
|
80
|
-
export type SchedulerFactory = (options: {
|
|
82
|
+
export type SchedulerFactory = (options: {
|
|
83
|
+
cwd: string
|
|
84
|
+
file: CronFile
|
|
85
|
+
onFire: (job: CronJob) => void
|
|
86
|
+
onCountStore?: (store: CountStore) => void
|
|
87
|
+
}) => Scheduler | Promise<Scheduler>
|
|
81
88
|
export type ChannelManagerFactory = typeof createChannelManager
|
|
82
89
|
export type TunnelManagerFactory = (options: TunnelManagerOptions) => TunnelManager
|
|
83
90
|
|
|
@@ -419,9 +426,23 @@ export async function startAgent({
|
|
|
419
426
|
})
|
|
420
427
|
subagentConsumer.start()
|
|
421
428
|
|
|
429
|
+
// Populated by startScheduler's factory (onCountStore). The consumer
|
|
430
|
+
// subscribes before this is set, but only touches the holder at fire time
|
|
431
|
+
// (reading the count via `get` and recording it via `increment`) — and the
|
|
432
|
+
// scheduler (the sole cron publisher) is armed only AFTER the holder is
|
|
433
|
+
// populated, so no count-limited fire can observe an undefined holder. If
|
|
434
|
+
// another cron publisher is ever added, create the store before this point.
|
|
435
|
+
let cronCountStore: CountStore | undefined
|
|
422
436
|
const cronConsumer = createCronConsumer({
|
|
423
437
|
stream,
|
|
424
438
|
cwd,
|
|
439
|
+
countStore: {
|
|
440
|
+
get: (id, job) => cronCountStore?.get(id, job) ?? 0,
|
|
441
|
+
// Holder is always set before any fire (see above); the `false` fallback
|
|
442
|
+
// fails safe — skip dispatch rather than run an uncounted count-job — for
|
|
443
|
+
// the unreachable case where a fire somehow predates the holder.
|
|
444
|
+
increment: (id, job, at) => cronCountStore?.increment(id, job, at) ?? Promise.resolve(false),
|
|
445
|
+
},
|
|
425
446
|
invokeHandler: async (job) => {
|
|
426
447
|
const snap = pluginRuntime.get()
|
|
427
448
|
const registered = snap.registry.cronJobs.find((j) => j.globalId === job.id)
|
|
@@ -532,6 +553,11 @@ export async function startAgent({
|
|
|
532
553
|
|
|
533
554
|
const internalJobs = () => pluginCronJobs(pluginRuntime.get().registry)
|
|
534
555
|
const factory = createSchedulerFor ?? makeDefaultSchedulerFactory(internalJobs)
|
|
556
|
+
// Subscribe the consumer BEFORE the scheduler arms any timers. The stream
|
|
557
|
+
// delivers only to live subscribers (no replay), so a fire published before
|
|
558
|
+
// the subscription exists would be lost. Subscribing to an empty stream is
|
|
559
|
+
// harmless when there are no jobs.
|
|
560
|
+
cronConsumer.start()
|
|
535
561
|
const scheduler = await startScheduler({
|
|
536
562
|
cwd,
|
|
537
563
|
loadCron,
|
|
@@ -539,10 +565,12 @@ export async function startAgent({
|
|
|
539
565
|
stream,
|
|
540
566
|
hasInternalJobs: internalJobs().length > 0,
|
|
541
567
|
getSubagents: () => pluginRuntime.get().subagents,
|
|
568
|
+
onCountStore: (store) => {
|
|
569
|
+
cronCountStore = store
|
|
570
|
+
},
|
|
542
571
|
})
|
|
543
572
|
|
|
544
573
|
if (scheduler) {
|
|
545
|
-
cronConsumer.start()
|
|
546
574
|
reloadRegistry.register(
|
|
547
575
|
createCronReloadable({ cwd, scheduler, internalJobs, getSubagents: () => pluginRuntime.get().subagents }),
|
|
548
576
|
)
|
|
@@ -721,6 +749,7 @@ export async function startAgent({
|
|
|
721
749
|
...mcpManagerOpt,
|
|
722
750
|
agentDir: cwd,
|
|
723
751
|
pluginRuntime,
|
|
752
|
+
getFiredCount: (job) => cronCountStore?.get(job.id, job) ?? 0,
|
|
724
753
|
claimController,
|
|
725
754
|
commandRunnerFactory,
|
|
726
755
|
tunnelManager,
|
|
@@ -838,6 +867,7 @@ async function startScheduler({
|
|
|
838
867
|
stream,
|
|
839
868
|
hasInternalJobs,
|
|
840
869
|
getSubagents,
|
|
870
|
+
onCountStore,
|
|
841
871
|
}: {
|
|
842
872
|
cwd: string
|
|
843
873
|
loadCron: LoadCronFn
|
|
@@ -845,6 +875,7 @@ async function startScheduler({
|
|
|
845
875
|
stream: Stream
|
|
846
876
|
hasInternalJobs: boolean
|
|
847
877
|
getSubagents?: () => SubagentRegistry
|
|
878
|
+
onCountStore?: (store: CountStore) => void
|
|
848
879
|
}): Promise<Scheduler | null> {
|
|
849
880
|
let result: LoadCronResult
|
|
850
881
|
const subagents = getSubagents?.()
|
|
@@ -864,13 +895,19 @@ async function startScheduler({
|
|
|
864
895
|
const onFire = (job: CronJob) => {
|
|
865
896
|
stream.publish({ target: { kind: 'cron', jobId: job.id }, payload: job })
|
|
866
897
|
}
|
|
867
|
-
const scheduler = createSchedulerFor({ cwd, file, onFire })
|
|
898
|
+
const scheduler = await createSchedulerFor({ cwd, file, onFire, onCountStore })
|
|
868
899
|
scheduler.start()
|
|
869
900
|
return scheduler
|
|
870
901
|
}
|
|
871
902
|
|
|
872
903
|
function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFactory {
|
|
873
|
-
return ({ file, onFire }) =>
|
|
904
|
+
return async ({ cwd, file, onFire, onCountStore }) => {
|
|
905
|
+
const jobs = [...file.jobs, ...internalJobs()]
|
|
906
|
+
const countStore = await createCountStore(cwd, jobs)
|
|
907
|
+
// Share the one store instance with the consumer's authoritative count gate.
|
|
908
|
+
onCountStore?.(countStore)
|
|
909
|
+
return createScheduler({ jobs, onFire, countStore })
|
|
910
|
+
}
|
|
874
911
|
}
|
|
875
912
|
|
|
876
913
|
// Exported for the regression test in `merge-subagents.test.ts`. The shim
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { LineAccountCredentials, LineConfig } from 'agent-messenger/line'
|
|
2
|
+
|
|
3
|
+
import { sendHttp } from '@/hostd/client'
|
|
4
|
+
|
|
5
|
+
import { type LineChannelBlock, lineChannelBlockSchema } from './schema'
|
|
6
|
+
import { SecretsBackend } from './storage'
|
|
7
|
+
|
|
8
|
+
export type SecretsLineCredentialStoreOptions =
|
|
9
|
+
| { mode: 'host'; secretsPath: string }
|
|
10
|
+
| { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
|
|
11
|
+
|
|
12
|
+
const EMPTY_BLOCK: LineChannelBlock = { currentAccount: null, accounts: {} }
|
|
13
|
+
|
|
14
|
+
export class SecretsLineCredentialStore {
|
|
15
|
+
private readonly backend: SecretsBackend
|
|
16
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: SecretsLineCredentialStoreOptions) {
|
|
19
|
+
this.backend = new SecretsBackend(options.secretsPath)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<LineConfig> {
|
|
23
|
+
return toLineConfig(this.readBlock())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async save(config: LineConfig): Promise<void> {
|
|
27
|
+
await this.writeBlock(() => fromLineConfig(config))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getAccount(id?: string): Promise<LineAccountCredentials | null> {
|
|
31
|
+
const config = await this.load()
|
|
32
|
+
if (id) return config.accounts[id] ?? null
|
|
33
|
+
if (!config.current_account) return null
|
|
34
|
+
return config.accounts[config.current_account] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async setAccount(account: LineAccountCredentials): Promise<void> {
|
|
38
|
+
await this.writeBlock((block) => {
|
|
39
|
+
const accounts = { ...block.accounts, [account.account_id]: account }
|
|
40
|
+
return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async removeAccount(id: string): Promise<void> {
|
|
45
|
+
await this.writeBlock((block) => {
|
|
46
|
+
const accounts = { ...block.accounts }
|
|
47
|
+
delete accounts[id]
|
|
48
|
+
const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
|
|
49
|
+
return { ...block, currentAccount, accounts }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async listAccounts(): Promise<Array<LineAccountCredentials & { is_current: boolean }>> {
|
|
54
|
+
const config = await this.load()
|
|
55
|
+
return Object.values(config.accounts).map((account) => ({
|
|
56
|
+
...account,
|
|
57
|
+
is_current: account.account_id === config.current_account,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async setCurrentAccount(id: string): Promise<void> {
|
|
62
|
+
await this.writeBlock((block) => ({ ...block, currentAccount: id }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private readBlock(): LineChannelBlock {
|
|
66
|
+
const channels =
|
|
67
|
+
this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
|
|
68
|
+
return parseBlock(channels?.line)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async writeBlock(update: (current: LineChannelBlock) => LineChannelBlock): Promise<void> {
|
|
72
|
+
return this.enqueueWrite(async () => {
|
|
73
|
+
if (this.options.mode === 'container') {
|
|
74
|
+
const next = update(this.readBlock())
|
|
75
|
+
const response = await sendHttp(
|
|
76
|
+
{
|
|
77
|
+
kind: 'secrets-patch',
|
|
78
|
+
containerName: this.options.containerName,
|
|
79
|
+
patch: { channels: { line: next } },
|
|
80
|
+
},
|
|
81
|
+
{ url: this.options.hostdUrl, token: this.options.restartToken },
|
|
82
|
+
)
|
|
83
|
+
if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await this.backend.updateChannelsAsync(async (channels) => {
|
|
88
|
+
const next = { ...channels, line: update(parseBlock(channels.line)) }
|
|
89
|
+
return { result: undefined, next }
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private enqueueWrite(op: () => Promise<void>): Promise<void> {
|
|
95
|
+
const next = this.writeChain.then(op, op)
|
|
96
|
+
this.writeChain = next.catch(() => {})
|
|
97
|
+
return next
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseBlock(value: unknown): LineChannelBlock {
|
|
102
|
+
if (value === undefined) return EMPTY_BLOCK
|
|
103
|
+
return lineChannelBlockSchema.parse(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toLineConfig(block: LineChannelBlock): LineConfig {
|
|
107
|
+
return { current_account: block.currentAccount, accounts: block.accounts }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fromLineConfig(config: LineConfig): LineChannelBlock {
|
|
111
|
+
return { currentAccount: config.current_account, accounts: config.accounts }
|
|
112
|
+
}
|
package/src/secrets/oauth-xai.ts
CHANGED
|
@@ -240,7 +240,7 @@ export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchF
|
|
|
240
240
|
callbacks.onAuth({
|
|
241
241
|
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
|
242
242
|
instructions:
|
|
243
|
-
'Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.',
|
|
243
|
+
'Complete login in your browser. Grok shows a code to copy on the "could not establish connection" page — paste that code here. If the browser is on another machine, paste the code (or the final redirect URL) here.',
|
|
244
244
|
})
|
|
245
245
|
|
|
246
246
|
if (server && callbacks.onManualCodeInput) {
|
package/src/secrets/schema.ts
CHANGED
|
@@ -55,6 +55,28 @@ const githubChannelSchema = z.object({
|
|
|
55
55
|
webhookSecret: secretFieldSchema,
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
const lineDeviceSchema = z.enum(['DESKTOPWIN', 'DESKTOPMAC', 'ANDROID', 'ANDROIDSECONDARY', 'IOS', 'IOSIPAD'])
|
|
59
|
+
|
|
60
|
+
// LINE persists a long-lived auth token (+ optional certificate that lets a
|
|
61
|
+
// later re-login skip the e-mail/PIN step on the same device). There is no
|
|
62
|
+
// encrypted-password / renewal-cron path the way KakaoTalk has — LINE tokens
|
|
63
|
+
// don't expire on a fixed short schedule, so the renewal fields are absent by
|
|
64
|
+
// design.
|
|
65
|
+
export const lineAccountRecordSchema = z.object({
|
|
66
|
+
account_id: z.string(),
|
|
67
|
+
auth_token: z.string(),
|
|
68
|
+
certificate: z.string().optional(),
|
|
69
|
+
device: lineDeviceSchema,
|
|
70
|
+
display_name: z.string().optional(),
|
|
71
|
+
created_at: z.string(),
|
|
72
|
+
updated_at: z.string(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export const lineChannelBlockSchema = z.object({
|
|
76
|
+
currentAccount: z.string().nullable(),
|
|
77
|
+
accounts: z.record(z.string(), lineAccountRecordSchema),
|
|
78
|
+
})
|
|
79
|
+
|
|
58
80
|
// Encrypted password envelope produced by src/secrets/encryption.ts. Optional
|
|
59
81
|
// in the schema because legacy v2 accounts (pre-renewal feature) don't have
|
|
60
82
|
// one; the renewal cron treats a missing envelope as "reauth required" and
|
|
@@ -109,6 +131,7 @@ export const channelsSchema = z
|
|
|
109
131
|
'discord-bot': discordBotChannelSchema.optional(),
|
|
110
132
|
github: githubChannelSchema.optional(),
|
|
111
133
|
'telegram-bot': telegramBotChannelSchema.optional(),
|
|
134
|
+
line: lineChannelBlockSchema.optional(),
|
|
112
135
|
kakaotalk: kakaoChannelBlockSchema.optional(),
|
|
113
136
|
})
|
|
114
137
|
.catchall(z.unknown())
|
|
@@ -130,6 +153,8 @@ export type Channels = z.infer<typeof channelsSchema>
|
|
|
130
153
|
export type GithubPatAuthBlock = z.infer<typeof githubPatAuthSchema>
|
|
131
154
|
export type GithubAppAuthBlock = z.infer<typeof githubAppAuthSchema>
|
|
132
155
|
export type GithubSecretsBlock = z.infer<typeof githubChannelSchema>
|
|
156
|
+
export type LineAccountRecord = z.infer<typeof lineAccountRecordSchema>
|
|
157
|
+
export type LineChannelBlock = z.infer<typeof lineChannelBlockSchema>
|
|
133
158
|
export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
|
|
134
159
|
export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
|
|
135
160
|
export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
|
package/src/server/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '@/agent/todo/continuation-wiring'
|
|
29
29
|
import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
|
|
30
30
|
import type { ChannelRouter } from '@/channels/router'
|
|
31
|
-
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
31
|
+
import { aggregateCronList, type CronJob, type CronListEntry, loadCron } from '@/cron'
|
|
32
32
|
import type { McpManager } from '@/mcp'
|
|
33
33
|
import type { HookBus } from '@/plugin'
|
|
34
34
|
import type { BrokerWsData, ContainerBroker } from '@/portbroker'
|
|
@@ -75,6 +75,9 @@ export type ServerOptions = {
|
|
|
75
75
|
mcpManager?: McpManager
|
|
76
76
|
agentDir?: string
|
|
77
77
|
pluginRuntime?: PluginRuntime
|
|
78
|
+
// Durable cron fire-progress lookup so `cron list` marks count-exhausted jobs
|
|
79
|
+
// as retired instead of showing a stale future fire time. Omit in tests/dev.
|
|
80
|
+
getFiredCount?: (job: CronJob) => number
|
|
78
81
|
containerName?: string
|
|
79
82
|
runtimeVersion?: string
|
|
80
83
|
tuiToken?: string
|
|
@@ -252,6 +255,7 @@ export function createServer({
|
|
|
252
255
|
mcpManager,
|
|
253
256
|
agentDir,
|
|
254
257
|
pluginRuntime,
|
|
258
|
+
getFiredCount,
|
|
255
259
|
containerName,
|
|
256
260
|
runtimeVersion,
|
|
257
261
|
tuiToken,
|
|
@@ -716,7 +720,7 @@ export function createServer({
|
|
|
716
720
|
}
|
|
717
721
|
|
|
718
722
|
if (msg.type === 'cron_list') {
|
|
719
|
-
await handleCronList(ws, msg.requestId, pluginRuntime, agentDir)
|
|
723
|
+
await handleCronList(ws, msg.requestId, pluginRuntime, agentDir, getFiredCount)
|
|
720
724
|
return
|
|
721
725
|
}
|
|
722
726
|
|
|
@@ -1186,6 +1190,7 @@ async function handleCronList(
|
|
|
1186
1190
|
requestId: string,
|
|
1187
1191
|
pluginRuntime: PluginRuntime | undefined,
|
|
1188
1192
|
agentDir: string | undefined,
|
|
1193
|
+
getFiredCount?: (job: CronJob) => number,
|
|
1189
1194
|
): Promise<void> {
|
|
1190
1195
|
if (agentDir === undefined) {
|
|
1191
1196
|
send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: 'agentDir not configured' } })
|
|
@@ -1208,7 +1213,12 @@ async function handleCronList(
|
|
|
1208
1213
|
const userJobs = loadResult.file?.jobs ?? []
|
|
1209
1214
|
const pluginJobs = snapshot?.registry.cronJobs ?? []
|
|
1210
1215
|
const nowMs = Date.now()
|
|
1211
|
-
const entries = aggregateCronList({
|
|
1216
|
+
const entries = aggregateCronList({
|
|
1217
|
+
userJobs,
|
|
1218
|
+
pluginJobs,
|
|
1219
|
+
now: nowMs,
|
|
1220
|
+
...(getFiredCount !== undefined ? { firedCount: getFiredCount } : {}),
|
|
1221
|
+
})
|
|
1212
1222
|
send(ws, {
|
|
1213
1223
|
type: 'cron_list_result',
|
|
1214
1224
|
requestId,
|
|
@@ -1541,9 +1551,12 @@ function toPayload(entry: CronListEntry): CronListEntryPayload {
|
|
|
1541
1551
|
id: entry.id,
|
|
1542
1552
|
source,
|
|
1543
1553
|
kind: entry.kind,
|
|
1544
|
-
schedule: entry.schedule,
|
|
1545
1554
|
enabled: entry.enabled,
|
|
1546
1555
|
nextFireMs: entry.nextFireMs,
|
|
1556
|
+
...(entry.schedule !== undefined ? { schedule: entry.schedule } : {}),
|
|
1557
|
+
...(entry.at !== undefined ? { at: entry.at } : {}),
|
|
1558
|
+
...(entry.until !== undefined ? { until: entry.until } : {}),
|
|
1559
|
+
...(entry.count !== undefined ? { count: entry.count } : {}),
|
|
1547
1560
|
...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
|
|
1548
1561
|
...(entry.scheduledByRole !== undefined ? { scheduledByRole: entry.scheduledByRole } : {}),
|
|
1549
1562
|
...(entry.scheduleError !== undefined ? { scheduleError: entry.scheduleError } : {}),
|
package/src/shared/protocol.ts
CHANGED
|
@@ -165,7 +165,10 @@ export type CronListEntryPayload = {
|
|
|
165
165
|
id: string
|
|
166
166
|
source: CronListSourcePayload
|
|
167
167
|
kind: 'prompt' | 'exec' | 'handler'
|
|
168
|
-
schedule
|
|
168
|
+
schedule?: string
|
|
169
|
+
at?: string
|
|
170
|
+
until?: string
|
|
171
|
+
count?: number
|
|
169
172
|
timezone?: string
|
|
170
173
|
enabled: boolean
|
|
171
174
|
scheduledByRole?: string
|