yzcode-cli 1.0.1 → 1.0.3
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/assistant/sessionHistory.ts +87 -0
- package/bootstrap/state.ts +1769 -0
- package/bridge/bridgeApi.ts +539 -0
- package/bridge/bridgeConfig.ts +48 -0
- package/bridge/bridgeDebug.ts +135 -0
- package/bridge/bridgeEnabled.ts +202 -0
- package/bridge/bridgeMain.ts +2999 -0
- package/bridge/bridgeMessaging.ts +461 -0
- package/bridge/bridgePermissionCallbacks.ts +43 -0
- package/bridge/bridgePointer.ts +210 -0
- package/bridge/bridgeStatusUtil.ts +163 -0
- package/bridge/bridgeUI.ts +530 -0
- package/bridge/capacityWake.ts +56 -0
- package/bridge/codeSessionApi.ts +168 -0
- package/bridge/createSession.ts +384 -0
- package/bridge/debugUtils.ts +141 -0
- package/bridge/envLessBridgeConfig.ts +165 -0
- package/bridge/flushGate.ts +71 -0
- package/bridge/inboundAttachments.ts +175 -0
- package/bridge/inboundMessages.ts +80 -0
- package/bridge/initReplBridge.ts +569 -0
- package/bridge/jwtUtils.ts +256 -0
- package/bridge/pollConfig.ts +110 -0
- package/bridge/pollConfigDefaults.ts +82 -0
- package/bridge/remoteBridgeCore.ts +1008 -0
- package/bridge/replBridge.ts +2406 -0
- package/bridge/replBridgeHandle.ts +36 -0
- package/bridge/replBridgeTransport.ts +370 -0
- package/bridge/sessionIdCompat.ts +57 -0
- package/bridge/sessionRunner.ts +550 -0
- package/bridge/trustedDevice.ts +210 -0
- package/bridge/types.ts +262 -0
- package/bridge/workSecret.ts +127 -0
- package/buddy/CompanionSprite.tsx +371 -0
- package/buddy/companion.ts +133 -0
- package/buddy/prompt.ts +36 -0
- package/buddy/sprites.ts +514 -0
- package/buddy/types.ts +148 -0
- package/buddy/useBuddyNotification.tsx +98 -0
- package/coordinator/coordinatorMode.ts +369 -0
- package/memdir/findRelevantMemories.ts +141 -0
- package/memdir/memdir.ts +507 -0
- package/memdir/memoryAge.ts +53 -0
- package/memdir/memoryScan.ts +94 -0
- package/memdir/memoryTypes.ts +271 -0
- package/memdir/paths.ts +278 -0
- package/memdir/teamMemPaths.ts +292 -0
- package/memdir/teamMemPrompts.ts +100 -0
- package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
- package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
- package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
- package/migrations/migrateFennecToOpus.ts +45 -0
- package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
- package/migrations/migrateOpusToOpus1m.ts +43 -0
- package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
- package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
- package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
- package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
- package/migrations/resetProToOpusDefault.ts +51 -0
- package/native-ts/color-diff/index.ts +999 -0
- package/native-ts/file-index/index.ts +370 -0
- package/native-ts/yoga-layout/enums.ts +134 -0
- package/native-ts/yoga-layout/index.ts +2578 -0
- package/outputStyles/loadOutputStylesDir.ts +98 -0
- package/package.json +22 -5
- package/plugins/builtinPlugins.ts +159 -0
- package/plugins/bundled/index.ts +23 -0
- package/schemas/hooks.ts +222 -0
- package/screens/Doctor.tsx +575 -0
- package/screens/REPL.tsx +5006 -0
- package/screens/ResumeConversation.tsx +399 -0
- package/server/createDirectConnectSession.ts +88 -0
- package/server/directConnectManager.ts +213 -0
- package/server/types.ts +57 -0
- package/skills/bundled/batch.ts +124 -0
- package/skills/bundled/claudeApi.ts +196 -0
- package/skills/bundled/claudeApiContent.ts +75 -0
- package/skills/bundled/claudeInChrome.ts +34 -0
- package/skills/bundled/debug.ts +103 -0
- package/skills/bundled/index.ts +79 -0
- package/skills/bundled/keybindings.ts +339 -0
- package/skills/bundled/loop.ts +92 -0
- package/skills/bundled/loremIpsum.ts +282 -0
- package/skills/bundled/remember.ts +82 -0
- package/skills/bundled/scheduleRemoteAgents.ts +447 -0
- package/skills/bundled/simplify.ts +69 -0
- package/skills/bundled/skillify.ts +197 -0
- package/skills/bundled/stuck.ts +79 -0
- package/skills/bundled/updateConfig.ts +475 -0
- package/skills/bundled/verify/SKILL.md +3 -0
- package/skills/bundled/verify/examples/cli.md +3 -0
- package/skills/bundled/verify/examples/server.md +3 -0
- package/skills/bundled/verify.ts +30 -0
- package/skills/bundled/verifyContent.ts +13 -0
- package/skills/bundledSkills.ts +220 -0
- package/skills/loadSkillsDir.ts +1086 -0
- package/skills/mcpSkillBuilders.ts +44 -0
- package/tasks/DreamTask/DreamTask.ts +157 -0
- package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
- package/tasks/InProcessTeammateTask/types.ts +121 -0
- package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
- package/tasks/LocalMainSessionTask.ts +479 -0
- package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
- package/tasks/LocalShellTask/guards.ts +41 -0
- package/tasks/LocalShellTask/killShellTasks.ts +76 -0
- package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
- package/tasks/pillLabel.ts +82 -0
- package/tasks/stopTask.ts +100 -0
- package/tasks/types.ts +46 -0
- package/upstreamproxy/relay.ts +455 -0
- package/upstreamproxy/upstreamproxy.ts +285 -0
- package/vim/motions.ts +82 -0
- package/vim/operators.ts +556 -0
- package/vim/textObjects.ts +186 -0
- package/vim/transitions.ts +490 -0
- package/vim/types.ts +199 -0
- package/voice/voiceModeEnabled.ts +54 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js'
|
|
3
|
+
import { lazySchema } from '../utils/lazySchema.js'
|
|
4
|
+
import { lt } from '../utils/semver.js'
|
|
5
|
+
import { isEnvLessBridgeEnabled } from './bridgeEnabled.js'
|
|
6
|
+
|
|
7
|
+
export type EnvLessBridgeConfig = {
|
|
8
|
+
// withRetry — init-phase backoff (createSession, POST /bridge, recovery /bridge)
|
|
9
|
+
init_retry_max_attempts: number
|
|
10
|
+
init_retry_base_delay_ms: number
|
|
11
|
+
init_retry_jitter_fraction: number
|
|
12
|
+
init_retry_max_delay_ms: number
|
|
13
|
+
// axios timeout for POST /sessions, POST /bridge, POST /archive
|
|
14
|
+
http_timeout_ms: number
|
|
15
|
+
// BoundedUUIDSet ring size (echo + re-delivery dedup)
|
|
16
|
+
uuid_dedup_buffer_size: number
|
|
17
|
+
// CCRClient worker heartbeat cadence. Server TTL is 60s — 20s gives 3× margin.
|
|
18
|
+
heartbeat_interval_ms: number
|
|
19
|
+
// ±fraction of interval — per-beat jitter to spread fleet load.
|
|
20
|
+
heartbeat_jitter_fraction: number
|
|
21
|
+
// Fire proactive JWT refresh this long before expires_in. Larger buffer =
|
|
22
|
+
// more frequent refresh (refresh cadence ≈ expires_in - buffer).
|
|
23
|
+
token_refresh_buffer_ms: number
|
|
24
|
+
// Archive POST timeout in teardown(). Distinct from http_timeout_ms because
|
|
25
|
+
// gracefulShutdown races runCleanupFunctions() against a 2s cap — a 10s
|
|
26
|
+
// axios timeout on a slow/stalled archive burns the whole budget on a
|
|
27
|
+
// request that forceExit will kill anyway.
|
|
28
|
+
teardown_archive_timeout_ms: number
|
|
29
|
+
// Deadline for onConnect after transport.connect(). If neither onConnect
|
|
30
|
+
// nor onClose fires before this, emit tengu_bridge_repl_connect_timeout
|
|
31
|
+
// — the only telemetry for the ~1% of sessions that emit `started` then
|
|
32
|
+
// go silent (no error, no event, just nothing).
|
|
33
|
+
connect_timeout_ms: number
|
|
34
|
+
// Semver floor for the env-less bridge path. Separate from the v1
|
|
35
|
+
// tengu_bridge_min_version config so a v2-specific bug can force upgrades
|
|
36
|
+
// without blocking v1 (env-based) clients, and vice versa.
|
|
37
|
+
min_version: string
|
|
38
|
+
// When true, tell users their claude.ai app may be too old to see v2
|
|
39
|
+
// sessions — lets us roll the v2 bridge before the app ships the new
|
|
40
|
+
// session-list query.
|
|
41
|
+
should_show_app_upgrade_message: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = {
|
|
45
|
+
init_retry_max_attempts: 3,
|
|
46
|
+
init_retry_base_delay_ms: 500,
|
|
47
|
+
init_retry_jitter_fraction: 0.25,
|
|
48
|
+
init_retry_max_delay_ms: 4000,
|
|
49
|
+
http_timeout_ms: 10_000,
|
|
50
|
+
uuid_dedup_buffer_size: 2000,
|
|
51
|
+
heartbeat_interval_ms: 20_000,
|
|
52
|
+
heartbeat_jitter_fraction: 0.1,
|
|
53
|
+
token_refresh_buffer_ms: 300_000,
|
|
54
|
+
teardown_archive_timeout_ms: 1500,
|
|
55
|
+
connect_timeout_ms: 15_000,
|
|
56
|
+
min_version: '0.0.0',
|
|
57
|
+
should_show_app_upgrade_message: false,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Floors reject the whole object on violation (fall back to DEFAULT) rather
|
|
61
|
+
// than partially trusting — same defense-in-depth as pollConfig.ts.
|
|
62
|
+
const envLessBridgeConfigSchema = lazySchema(() =>
|
|
63
|
+
z.object({
|
|
64
|
+
init_retry_max_attempts: z.number().int().min(1).max(10).default(3),
|
|
65
|
+
init_retry_base_delay_ms: z.number().int().min(100).default(500),
|
|
66
|
+
init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25),
|
|
67
|
+
init_retry_max_delay_ms: z.number().int().min(500).default(4000),
|
|
68
|
+
http_timeout_ms: z.number().int().min(2000).default(10_000),
|
|
69
|
+
uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000),
|
|
70
|
+
// Server TTL is 60s. Floor 5s prevents thrash; cap 30s keeps ≥2× margin.
|
|
71
|
+
heartbeat_interval_ms: z
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(5000)
|
|
75
|
+
.max(30_000)
|
|
76
|
+
.default(20_000),
|
|
77
|
+
// ±fraction per beat. Cap 0.5: at max interval (30s) × 1.5 = 45s worst case,
|
|
78
|
+
// still under the 60s TTL.
|
|
79
|
+
heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1),
|
|
80
|
+
// Floor 30s prevents tight-looping. Cap 30min rejects buffer-vs-delay
|
|
81
|
+
// semantic inversion: ops entering expires_in-5min (the *delay until
|
|
82
|
+
// refresh*) instead of 5min (the *buffer before expiry*) yields
|
|
83
|
+
// delayMs = expires_in - buffer ≈ 5min instead of ≈4h. Both are positive
|
|
84
|
+
// durations so .min() alone can't distinguish; .max() catches the
|
|
85
|
+
// inverted value since buffer ≥ 30min is nonsensical for a multi-hour JWT.
|
|
86
|
+
token_refresh_buffer_ms: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.min(30_000)
|
|
90
|
+
.max(1_800_000)
|
|
91
|
+
.default(300_000),
|
|
92
|
+
// Cap 2000 keeps this under gracefulShutdown's 2s cleanup race — a higher
|
|
93
|
+
// timeout just lies to axios since forceExit kills the socket regardless.
|
|
94
|
+
teardown_archive_timeout_ms: z
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.min(500)
|
|
98
|
+
.max(2000)
|
|
99
|
+
.default(1500),
|
|
100
|
+
// Observed p99 connect is ~2-3s; 15s is ~5× headroom. Floor 5s bounds
|
|
101
|
+
// false-positive rate under transient slowness; cap 60s bounds how long
|
|
102
|
+
// a truly-stalled session stays dark.
|
|
103
|
+
connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000),
|
|
104
|
+
min_version: z
|
|
105
|
+
.string()
|
|
106
|
+
.refine(v => {
|
|
107
|
+
try {
|
|
108
|
+
lt(v, '0.0.0')
|
|
109
|
+
return true
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.default('0.0.0'),
|
|
115
|
+
should_show_app_upgrade_message: z.boolean().default(false),
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetch the env-less bridge timing config from GrowthBook. Read once per
|
|
121
|
+
* initEnvLessBridgeCore call — config is fixed for the lifetime of a bridge
|
|
122
|
+
* session.
|
|
123
|
+
*
|
|
124
|
+
* Uses the blocking getter (not _CACHED_MAY_BE_STALE) because /remote-control
|
|
125
|
+
* runs well after GrowthBook init — initializeGrowthBook() resolves instantly,
|
|
126
|
+
* so there's no startup penalty, and we get the fresh in-memory remoteEval
|
|
127
|
+
* value instead of the stale-on-first-read disk cache. The _DEPRECATED suffix
|
|
128
|
+
* warns against startup-path usage, which this isn't.
|
|
129
|
+
*/
|
|
130
|
+
export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
|
|
131
|
+
const raw = await getFeatureValue_DEPRECATED<unknown>(
|
|
132
|
+
'tengu_bridge_repl_v2_config',
|
|
133
|
+
DEFAULT_ENV_LESS_BRIDGE_CONFIG,
|
|
134
|
+
)
|
|
135
|
+
const parsed = envLessBridgeConfigSchema().safeParse(raw)
|
|
136
|
+
return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns an error message if the current CLI version is below the minimum
|
|
141
|
+
* required for the env-less (v2) bridge path, or null if the version is fine.
|
|
142
|
+
*
|
|
143
|
+
* v2 analogue of checkBridgeMinVersion() — reads from tengu_bridge_repl_v2_config
|
|
144
|
+
* instead of tengu_bridge_min_version so the two implementations can enforce
|
|
145
|
+
* independent floors.
|
|
146
|
+
*/
|
|
147
|
+
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
|
|
148
|
+
const cfg = await getEnvLessBridgeConfig()
|
|
149
|
+
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
|
|
150
|
+
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
|
|
151
|
+
}
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether to nudge users toward upgrading their claude.ai app when a
|
|
157
|
+
* Remote Control session starts. True only when the v2 bridge is active
|
|
158
|
+
* AND the should_show_app_upgrade_message config bit is set — lets us
|
|
159
|
+
* roll the v2 bridge before the app ships the new session-list query.
|
|
160
|
+
*/
|
|
161
|
+
export async function shouldShowAppUpgradeMessage(): Promise<boolean> {
|
|
162
|
+
if (!isEnvLessBridgeEnabled()) return false
|
|
163
|
+
const cfg = await getEnvLessBridgeConfig()
|
|
164
|
+
return cfg.should_show_app_upgrade_message
|
|
165
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State machine for gating message writes during an initial flush.
|
|
3
|
+
*
|
|
4
|
+
* When a bridge session starts, historical messages are flushed to the
|
|
5
|
+
* server via a single HTTP POST. During that flush, new messages must
|
|
6
|
+
* be queued to prevent them from arriving at the server interleaved
|
|
7
|
+
* with the historical messages.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle:
|
|
10
|
+
* start() → enqueue() returns true, items are queued
|
|
11
|
+
* end() → returns queued items for draining, enqueue() returns false
|
|
12
|
+
* drop() → discards queued items (permanent transport close)
|
|
13
|
+
* deactivate() → clears active flag without dropping items
|
|
14
|
+
* (transport replacement — new transport will drain)
|
|
15
|
+
*/
|
|
16
|
+
export class FlushGate<T> {
|
|
17
|
+
private _active = false
|
|
18
|
+
private _pending: T[] = []
|
|
19
|
+
|
|
20
|
+
get active(): boolean {
|
|
21
|
+
return this._active
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get pendingCount(): number {
|
|
25
|
+
return this._pending.length
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mark flush as in-progress. enqueue() will start queuing items. */
|
|
29
|
+
start(): void {
|
|
30
|
+
this._active = true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* End the flush and return any queued items for draining.
|
|
35
|
+
* Caller is responsible for sending the returned items.
|
|
36
|
+
*/
|
|
37
|
+
end(): T[] {
|
|
38
|
+
this._active = false
|
|
39
|
+
return this._pending.splice(0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* If flush is active, queue the items and return true.
|
|
44
|
+
* If flush is not active, return false (caller should send directly).
|
|
45
|
+
*/
|
|
46
|
+
enqueue(...items: T[]): boolean {
|
|
47
|
+
if (!this._active) return false
|
|
48
|
+
this._pending.push(...items)
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discard all queued items (permanent transport close).
|
|
54
|
+
* Returns the number of items dropped.
|
|
55
|
+
*/
|
|
56
|
+
drop(): number {
|
|
57
|
+
this._active = false
|
|
58
|
+
const count = this._pending.length
|
|
59
|
+
this._pending.length = 0
|
|
60
|
+
return count
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear the active flag without dropping queued items.
|
|
65
|
+
* Used when the transport is replaced (onWorkReceived) — the new
|
|
66
|
+
* transport's flush will drain the pending items.
|
|
67
|
+
*/
|
|
68
|
+
deactivate(): void {
|
|
69
|
+
this._active = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve file_uuid attachments on inbound bridge user messages.
|
|
3
|
+
*
|
|
4
|
+
* Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
|
|
5
|
+
* alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
|
|
6
|
+
* (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
|
|
7
|
+
* return @path refs to prepend. Claude's Read tool takes it from there.
|
|
8
|
+
*
|
|
9
|
+
* Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
|
|
10
|
+
* skips that attachment. The message still reaches Claude, just without @path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
|
14
|
+
import axios from 'axios'
|
|
15
|
+
import { randomUUID } from 'crypto'
|
|
16
|
+
import { mkdir, writeFile } from 'fs/promises'
|
|
17
|
+
import { basename, join } from 'path'
|
|
18
|
+
import { z } from 'zod/v4'
|
|
19
|
+
import { getSessionId } from '../bootstrap/state.js'
|
|
20
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
21
|
+
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|
22
|
+
import { lazySchema } from '../utils/lazySchema.js'
|
|
23
|
+
import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
|
|
24
|
+
|
|
25
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
26
|
+
|
|
27
|
+
function debug(msg: string): void {
|
|
28
|
+
logForDebugging(`[bridge:inbound-attach] ${msg}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const attachmentSchema = lazySchema(() =>
|
|
32
|
+
z.object({
|
|
33
|
+
file_uuid: z.string(),
|
|
34
|
+
file_name: z.string(),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
|
|
38
|
+
|
|
39
|
+
export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
|
|
40
|
+
|
|
41
|
+
/** Pull file_attachments off a loosely-typed inbound message. */
|
|
42
|
+
export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
|
|
43
|
+
if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
|
|
47
|
+
return parsed.success ? parsed.data : []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Strip path components and keep only filename-safe chars. file_name comes
|
|
52
|
+
* from the network (web composer), so treat it as untrusted even though the
|
|
53
|
+
* composer controls it.
|
|
54
|
+
*/
|
|
55
|
+
function sanitizeFileName(name: string): string {
|
|
56
|
+
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
57
|
+
return base || 'attachment'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function uploadsDir(): string {
|
|
61
|
+
return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetch + write one attachment. Returns the absolute path on success,
|
|
66
|
+
* undefined on any failure.
|
|
67
|
+
*/
|
|
68
|
+
async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
|
|
69
|
+
const token = getBridgeAccessToken()
|
|
70
|
+
if (!token) {
|
|
71
|
+
debug('skip: no oauth token')
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let data: Buffer
|
|
76
|
+
try {
|
|
77
|
+
// getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
|
|
78
|
+
// CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
|
|
79
|
+
// FedStart URL degrades to "no @path" instead of crashing print.ts's
|
|
80
|
+
// reader loop (which has no catch around the await).
|
|
81
|
+
const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
|
|
82
|
+
const response = await axios.get(url, {
|
|
83
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
84
|
+
responseType: 'arraybuffer',
|
|
85
|
+
timeout: DOWNLOAD_TIMEOUT_MS,
|
|
86
|
+
validateStatus: () => true,
|
|
87
|
+
})
|
|
88
|
+
if (response.status !== 200) {
|
|
89
|
+
debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
data = Buffer.from(response.data)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
debug(`fetch ${att.file_uuid} threw: ${e}`)
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// uuid-prefix makes collisions impossible across messages and within one
|
|
99
|
+
// (same filename, different files). 8 chars is enough — this isn't security.
|
|
100
|
+
const safeName = sanitizeFileName(att.file_name)
|
|
101
|
+
const prefix = (
|
|
102
|
+
att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
|
|
103
|
+
).replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
104
|
+
const dir = uploadsDir()
|
|
105
|
+
const outPath = join(dir, `${prefix}-${safeName}`)
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await mkdir(dir, { recursive: true })
|
|
109
|
+
await writeFile(outPath, data)
|
|
110
|
+
} catch (e) {
|
|
111
|
+
debug(`write ${outPath} failed: ${e}`)
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`)
|
|
116
|
+
return outPath
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve all attachments on an inbound message to a prefix string of
|
|
121
|
+
* @path refs. Empty string if none resolved.
|
|
122
|
+
*/
|
|
123
|
+
export async function resolveInboundAttachments(
|
|
124
|
+
attachments: InboundAttachment[],
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
if (attachments.length === 0) return ''
|
|
127
|
+
debug(`resolving ${attachments.length} attachment(s)`)
|
|
128
|
+
const paths = await Promise.all(attachments.map(resolveOne))
|
|
129
|
+
const ok = paths.filter((p): p is string => p !== undefined)
|
|
130
|
+
if (ok.length === 0) return ''
|
|
131
|
+
// Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
|
|
132
|
+
// first space, which breaks any home dir with spaces (/Users/John Smith/).
|
|
133
|
+
return ok.map(p => `@"${p}"`).join(' ') + ' '
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prepend @path refs to content, whichever form it's in.
|
|
138
|
+
* Targets the LAST text block — processUserInputBase reads inputString
|
|
139
|
+
* from processedBlocks[processedBlocks.length - 1], so putting refs in
|
|
140
|
+
* block[0] means they're silently ignored for [text, image] content.
|
|
141
|
+
*/
|
|
142
|
+
export function prependPathRefs(
|
|
143
|
+
content: string | Array<ContentBlockParam>,
|
|
144
|
+
prefix: string,
|
|
145
|
+
): string | Array<ContentBlockParam> {
|
|
146
|
+
if (!prefix) return content
|
|
147
|
+
if (typeof content === 'string') return prefix + content
|
|
148
|
+
const i = content.findLastIndex(b => b.type === 'text')
|
|
149
|
+
if (i !== -1) {
|
|
150
|
+
const b = content[i]!
|
|
151
|
+
if (b.type === 'text') {
|
|
152
|
+
return [
|
|
153
|
+
...content.slice(0, i),
|
|
154
|
+
{ ...b, text: prefix + b.text },
|
|
155
|
+
...content.slice(i + 1),
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// No text block — append one at the end so it's last.
|
|
160
|
+
return [...content, { type: 'text', text: prefix.trimEnd() }]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Convenience: extract + resolve + prepend. No-op when the message has no
|
|
165
|
+
* file_attachments field (fast path — no network, returns same reference).
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveAndPrepend(
|
|
168
|
+
msg: unknown,
|
|
169
|
+
content: string | Array<ContentBlockParam>,
|
|
170
|
+
): Promise<string | Array<ContentBlockParam>> {
|
|
171
|
+
const attachments = extractInboundAttachments(msg)
|
|
172
|
+
if (attachments.length === 0) return content
|
|
173
|
+
const prefix = await resolveInboundAttachments(attachments)
|
|
174
|
+
return prependPathRefs(content, prefix)
|
|
175
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Base64ImageSource,
|
|
3
|
+
ContentBlockParam,
|
|
4
|
+
ImageBlockParam,
|
|
5
|
+
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
|
6
|
+
import type { UUID } from 'crypto'
|
|
7
|
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
|
8
|
+
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Process an inbound user message from the bridge, extracting content
|
|
12
|
+
* and UUID for enqueueing. Supports both string content and
|
|
13
|
+
* ContentBlockParam[] (e.g. messages containing images).
|
|
14
|
+
*
|
|
15
|
+
* Normalizes image blocks from bridge clients that may use camelCase
|
|
16
|
+
* `mediaType` instead of snake_case `media_type` (mobile-apps#5825).
|
|
17
|
+
*
|
|
18
|
+
* Returns the extracted fields, or undefined if the message should be
|
|
19
|
+
* skipped (non-user type, missing/empty content).
|
|
20
|
+
*/
|
|
21
|
+
export function extractInboundMessageFields(
|
|
22
|
+
msg: SDKMessage,
|
|
23
|
+
):
|
|
24
|
+
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
|
|
25
|
+
| undefined {
|
|
26
|
+
if (msg.type !== 'user') return undefined
|
|
27
|
+
const content = msg.message?.content
|
|
28
|
+
if (!content) return undefined
|
|
29
|
+
if (Array.isArray(content) && content.length === 0) return undefined
|
|
30
|
+
|
|
31
|
+
const uuid =
|
|
32
|
+
'uuid' in msg && typeof msg.uuid === 'string'
|
|
33
|
+
? (msg.uuid as UUID)
|
|
34
|
+
: undefined
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
|
|
38
|
+
uuid,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize image content blocks from bridge clients. iOS/web clients may
|
|
44
|
+
* send `mediaType` (camelCase) instead of `media_type` (snake_case), or
|
|
45
|
+
* omit the field entirely. Without normalization, the bad block poisons
|
|
46
|
+
* the session — every subsequent API call fails with
|
|
47
|
+
* "media_type: Field required".
|
|
48
|
+
*
|
|
49
|
+
* Fast-path scan returns the original array reference when no
|
|
50
|
+
* normalization is needed (zero allocation on the happy path).
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeImageBlocks(
|
|
53
|
+
blocks: Array<ContentBlockParam>,
|
|
54
|
+
): Array<ContentBlockParam> {
|
|
55
|
+
if (!blocks.some(isMalformedBase64Image)) return blocks
|
|
56
|
+
|
|
57
|
+
return blocks.map(block => {
|
|
58
|
+
if (!isMalformedBase64Image(block)) return block
|
|
59
|
+
const src = block.source as unknown as Record<string, unknown>
|
|
60
|
+
const mediaType =
|
|
61
|
+
typeof src.mediaType === 'string' && src.mediaType
|
|
62
|
+
? src.mediaType
|
|
63
|
+
: detectImageFormatFromBase64(block.source.data)
|
|
64
|
+
return {
|
|
65
|
+
...block,
|
|
66
|
+
source: {
|
|
67
|
+
type: 'base64' as const,
|
|
68
|
+
media_type: mediaType as Base64ImageSource['media_type'],
|
|
69
|
+
data: block.source.data,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isMalformedBase64Image(
|
|
76
|
+
block: ContentBlockParam,
|
|
77
|
+
): block is ImageBlockParam & { source: Base64ImageSource } {
|
|
78
|
+
if (block.type !== 'image' || block.source?.type !== 'base64') return false
|
|
79
|
+
return !(block.source as unknown as Record<string, unknown>).media_type
|
|
80
|
+
}
|