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.
Files changed (117) hide show
  1. package/assistant/sessionHistory.ts +87 -0
  2. package/bootstrap/state.ts +1769 -0
  3. package/bridge/bridgeApi.ts +539 -0
  4. package/bridge/bridgeConfig.ts +48 -0
  5. package/bridge/bridgeDebug.ts +135 -0
  6. package/bridge/bridgeEnabled.ts +202 -0
  7. package/bridge/bridgeMain.ts +2999 -0
  8. package/bridge/bridgeMessaging.ts +461 -0
  9. package/bridge/bridgePermissionCallbacks.ts +43 -0
  10. package/bridge/bridgePointer.ts +210 -0
  11. package/bridge/bridgeStatusUtil.ts +163 -0
  12. package/bridge/bridgeUI.ts +530 -0
  13. package/bridge/capacityWake.ts +56 -0
  14. package/bridge/codeSessionApi.ts +168 -0
  15. package/bridge/createSession.ts +384 -0
  16. package/bridge/debugUtils.ts +141 -0
  17. package/bridge/envLessBridgeConfig.ts +165 -0
  18. package/bridge/flushGate.ts +71 -0
  19. package/bridge/inboundAttachments.ts +175 -0
  20. package/bridge/inboundMessages.ts +80 -0
  21. package/bridge/initReplBridge.ts +569 -0
  22. package/bridge/jwtUtils.ts +256 -0
  23. package/bridge/pollConfig.ts +110 -0
  24. package/bridge/pollConfigDefaults.ts +82 -0
  25. package/bridge/remoteBridgeCore.ts +1008 -0
  26. package/bridge/replBridge.ts +2406 -0
  27. package/bridge/replBridgeHandle.ts +36 -0
  28. package/bridge/replBridgeTransport.ts +370 -0
  29. package/bridge/sessionIdCompat.ts +57 -0
  30. package/bridge/sessionRunner.ts +550 -0
  31. package/bridge/trustedDevice.ts +210 -0
  32. package/bridge/types.ts +262 -0
  33. package/bridge/workSecret.ts +127 -0
  34. package/buddy/CompanionSprite.tsx +371 -0
  35. package/buddy/companion.ts +133 -0
  36. package/buddy/prompt.ts +36 -0
  37. package/buddy/sprites.ts +514 -0
  38. package/buddy/types.ts +148 -0
  39. package/buddy/useBuddyNotification.tsx +98 -0
  40. package/coordinator/coordinatorMode.ts +369 -0
  41. package/memdir/findRelevantMemories.ts +141 -0
  42. package/memdir/memdir.ts +507 -0
  43. package/memdir/memoryAge.ts +53 -0
  44. package/memdir/memoryScan.ts +94 -0
  45. package/memdir/memoryTypes.ts +271 -0
  46. package/memdir/paths.ts +278 -0
  47. package/memdir/teamMemPaths.ts +292 -0
  48. package/memdir/teamMemPrompts.ts +100 -0
  49. package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
  50. package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
  51. package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
  52. package/migrations/migrateFennecToOpus.ts +45 -0
  53. package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
  54. package/migrations/migrateOpusToOpus1m.ts +43 -0
  55. package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
  56. package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
  57. package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
  58. package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
  59. package/migrations/resetProToOpusDefault.ts +51 -0
  60. package/native-ts/color-diff/index.ts +999 -0
  61. package/native-ts/file-index/index.ts +370 -0
  62. package/native-ts/yoga-layout/enums.ts +134 -0
  63. package/native-ts/yoga-layout/index.ts +2578 -0
  64. package/outputStyles/loadOutputStylesDir.ts +98 -0
  65. package/package.json +22 -5
  66. package/plugins/builtinPlugins.ts +159 -0
  67. package/plugins/bundled/index.ts +23 -0
  68. package/schemas/hooks.ts +222 -0
  69. package/screens/Doctor.tsx +575 -0
  70. package/screens/REPL.tsx +5006 -0
  71. package/screens/ResumeConversation.tsx +399 -0
  72. package/server/createDirectConnectSession.ts +88 -0
  73. package/server/directConnectManager.ts +213 -0
  74. package/server/types.ts +57 -0
  75. package/skills/bundled/batch.ts +124 -0
  76. package/skills/bundled/claudeApi.ts +196 -0
  77. package/skills/bundled/claudeApiContent.ts +75 -0
  78. package/skills/bundled/claudeInChrome.ts +34 -0
  79. package/skills/bundled/debug.ts +103 -0
  80. package/skills/bundled/index.ts +79 -0
  81. package/skills/bundled/keybindings.ts +339 -0
  82. package/skills/bundled/loop.ts +92 -0
  83. package/skills/bundled/loremIpsum.ts +282 -0
  84. package/skills/bundled/remember.ts +82 -0
  85. package/skills/bundled/scheduleRemoteAgents.ts +447 -0
  86. package/skills/bundled/simplify.ts +69 -0
  87. package/skills/bundled/skillify.ts +197 -0
  88. package/skills/bundled/stuck.ts +79 -0
  89. package/skills/bundled/updateConfig.ts +475 -0
  90. package/skills/bundled/verify/SKILL.md +3 -0
  91. package/skills/bundled/verify/examples/cli.md +3 -0
  92. package/skills/bundled/verify/examples/server.md +3 -0
  93. package/skills/bundled/verify.ts +30 -0
  94. package/skills/bundled/verifyContent.ts +13 -0
  95. package/skills/bundledSkills.ts +220 -0
  96. package/skills/loadSkillsDir.ts +1086 -0
  97. package/skills/mcpSkillBuilders.ts +44 -0
  98. package/tasks/DreamTask/DreamTask.ts +157 -0
  99. package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
  100. package/tasks/InProcessTeammateTask/types.ts +121 -0
  101. package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
  102. package/tasks/LocalMainSessionTask.ts +479 -0
  103. package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
  104. package/tasks/LocalShellTask/guards.ts +41 -0
  105. package/tasks/LocalShellTask/killShellTasks.ts +76 -0
  106. package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
  107. package/tasks/pillLabel.ts +82 -0
  108. package/tasks/stopTask.ts +100 -0
  109. package/tasks/types.ts +46 -0
  110. package/upstreamproxy/relay.ts +455 -0
  111. package/upstreamproxy/upstreamproxy.ts +285 -0
  112. package/vim/motions.ts +82 -0
  113. package/vim/operators.ts +556 -0
  114. package/vim/textObjects.ts +186 -0
  115. package/vim/transitions.ts +490 -0
  116. package/vim/types.ts +199 -0
  117. 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
+ }