mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: 添加工具类命令(teleport、recap、break-cache、env、tui 等)
- /teleport: 从 claude.ai 恢复会话 - /recap: 生成会话摘要 - /break-cache: 提示缓存管理(once/always/off/status) - /env: 环境信息展示(含密钥脱敏) - /tui: 无闪烁 TUI 模式管理 - /onboarding: 引导流程 - /perf-issue: 性能问题诊断 - /debug-tool-call: 工具调用调试 - /usage: 用量统计(合并 /cost 和 /stats 别名) Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
58
src/commands/teleport/__tests__/index.test.ts
Normal file
58
src/commands/teleport/__tests__/index.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tests for teleport/index.ts — command metadata + load() body.
|
||||
* We do NOT mock launchTeleport to avoid polluting launchTeleport.test.ts
|
||||
* via Bun's process-level mock.module cache.
|
||||
* load() is tested by verifying it resolves to an object with a call function.
|
||||
*/
|
||||
import { beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
let cmd: {
|
||||
load?: () => Promise<{ call: unknown }>
|
||||
isEnabled?: () => boolean
|
||||
name?: string
|
||||
type?: string
|
||||
aliases?: string[]
|
||||
getBridgeInvocationError?: (args: string) => string | undefined
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
cmd = mod.default as typeof cmd
|
||||
})
|
||||
|
||||
describe('teleport index', () => {
|
||||
test('command name is teleport', () => {
|
||||
expect(cmd.name).toBe('teleport')
|
||||
})
|
||||
|
||||
test('command type is local-jsx', () => {
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
})
|
||||
|
||||
test('isEnabled returns true', () => {
|
||||
expect(cmd.isEnabled?.()).toBe(true)
|
||||
})
|
||||
|
||||
test('aliases includes tp', () => {
|
||||
expect(cmd.aliases).toContain('tp')
|
||||
})
|
||||
|
||||
test('getBridgeInvocationError returns error string (not bridge-safe)', () => {
|
||||
const err = cmd.getBridgeInvocationError?.('anything')
|
||||
expect(typeof err).toBe('string')
|
||||
expect(err).toContain('not bridge-safe')
|
||||
})
|
||||
|
||||
test('load() exists and is a function', () => {
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
|
||||
test('load() resolves to object with call function', async () => {
|
||||
const loaded = await cmd.load!()
|
||||
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
|
||||
})
|
||||
})
|
||||
388
src/commands/teleport/__tests__/launchTeleport.test.ts
Normal file
388
src/commands/teleport/__tests__/launchTeleport.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import type { LogOption } from '../../../types/logs.js'
|
||||
import type { LocalJSXCommandCall } from '../../../types/command.js'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
// ── Mock module-level side effects BEFORE any imports ──
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
// ── Teleport utilities ──
|
||||
const validateGitStateMock = mock(() => Promise.resolve())
|
||||
const teleportResumeMock = mock(
|
||||
(_id: string, _onProgress?: (stage: string) => void) =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
)
|
||||
|
||||
mock.module('src/utils/teleport.js', () => ({
|
||||
validateGitState: validateGitStateMock,
|
||||
teleportResumeCodeSession: teleportResumeMock,
|
||||
processMessagesForTeleportResume: mock(
|
||||
(_msgs: unknown[], _err: unknown) => [],
|
||||
),
|
||||
checkOutTeleportedSessionBranch: mock(() =>
|
||||
Promise.resolve({ branchName: 'main', branchError: null }),
|
||||
),
|
||||
validateSessionRepository: mock(() => Promise.resolve({ status: 'match' })),
|
||||
teleportToRemoteWithErrorHandling: mock(() => Promise.resolve(null)),
|
||||
teleportFromSessionsAPI: mock(() =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
),
|
||||
pollRemoteSessionEvents: mock(() => Promise.resolve([])),
|
||||
teleportToRemote: mock(() => Promise.resolve(null)),
|
||||
archiveRemoteSession: mock(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
// ── Sessions API mock ──
|
||||
const fetchSessionsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'session_01ABC',
|
||||
title: 'Test session',
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
},
|
||||
]),
|
||||
)
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
fetchCodeSessionsFromSessionsAPI: fetchSessionsMock,
|
||||
}))
|
||||
|
||||
// ── Session storage ──
|
||||
const mockLog: LogOption = {
|
||||
date: '2026-04-29',
|
||||
messages: [],
|
||||
value: 0,
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
firstPrompt: '',
|
||||
messageCount: 0,
|
||||
isSidechain: false,
|
||||
}
|
||||
const getLastSessionLogMock = mock(() => Promise.resolve(mockLog))
|
||||
mock.module('src/utils/sessionStorage.js', () => ({
|
||||
getLastSessionLog: getLastSessionLogMock,
|
||||
}))
|
||||
|
||||
// ── Analytics ──
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── Import SUT after mocks ──
|
||||
let callTeleport: LocalJSXCommandCall
|
||||
|
||||
beforeAll(async () => {
|
||||
const sut = await import('../launchTeleport.js')
|
||||
callTeleport = sut.callTeleport
|
||||
})
|
||||
|
||||
// ── Test helpers ──
|
||||
const onDone = mock((_result?: string, _opts?: unknown) => {})
|
||||
const resumeMockFn = mock(() => Promise.resolve())
|
||||
|
||||
function makeContext(withResume = true) {
|
||||
return {
|
||||
abortController: new AbortController(),
|
||||
resume: withResume ? resumeMockFn : undefined,
|
||||
} as unknown as Parameters<typeof callTeleport>[1]
|
||||
}
|
||||
|
||||
function getLoggedEvents(): string[] {
|
||||
return (logEventMock.mock.calls as unknown as [string, unknown][]).map(
|
||||
c => c[0],
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
validateGitStateMock.mockClear()
|
||||
teleportResumeMock.mockClear()
|
||||
getLastSessionLogMock.mockClear()
|
||||
fetchSessionsMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
onDone.mockClear()
|
||||
resumeMockFn.mockClear()
|
||||
// Restore default happy-path implementations
|
||||
validateGitStateMock.mockImplementation(() => Promise.resolve())
|
||||
teleportResumeMock.mockImplementation(
|
||||
(_id: string, _onProgress?: (stage: string) => void) =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
)
|
||||
getLastSessionLogMock.mockImplementation(() => Promise.resolve(mockLog))
|
||||
fetchSessionsMock.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'session_01ABC',
|
||||
title: 'Test session',
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
describe('callTeleport', () => {
|
||||
test('empty args: fetches sessions list and shows picker', async () => {
|
||||
await callTeleport(onDone, makeContext(), ' ')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Available sessions/)
|
||||
expect(validateGitStateMock).not.toHaveBeenCalled()
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_started')
|
||||
expect(events).toContain('tengu_teleport_source_decision')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with generic error → fetch_fail event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('network timeout')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/failed to fetch sessions/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_fail')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with 401/forbidden → fetch_forbidden event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('403 Forbidden: access denied')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/permission denied/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_forbidden')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with 404/not-found → fetch_not_found event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('404 Not Found')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/404/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_not_found')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with token/unauthorized → bad_token event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('unauthorized: invalid token')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/authentication error/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_token')
|
||||
})
|
||||
|
||||
test('empty args + empty sessions list → teleport_null event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() => Promise.resolve([]))
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/No active sessions/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_null')
|
||||
})
|
||||
|
||||
test('empty args + exactly PICKER_PAGE_CAP sessions → page_cap event', async () => {
|
||||
// 20 sessions triggers the page cap log
|
||||
const sessions = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `session_${i}`,
|
||||
title: `Session ${i}`,
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
}))
|
||||
fetchSessionsMock.mockImplementationOnce(() => Promise.resolve(sessions))
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_page_cap')
|
||||
})
|
||||
|
||||
test('--print flag with no session id → shows picker in print mode', async () => {
|
||||
await callTeleport(onDone, makeContext(), '--print')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Available sessions/)
|
||||
})
|
||||
|
||||
test('short non-UUID session id is rejected without calling teleport', async () => {
|
||||
await callTeleport(onDone, makeContext(), 'abc')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Invalid session id/)
|
||||
expect(validateGitStateMock).not.toHaveBeenCalled()
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('valid session id + git unclean → reports error, skips resume', async () => {
|
||||
validateGitStateMock.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
'Git working directory is not clean. Please commit or stash your changes.',
|
||||
),
|
||||
),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Cannot teleport/)
|
||||
expect(firstArg).toMatch(/not clean/)
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('valid session id + clean git → calls teleportResumeCodeSession + context.resume', async () => {
|
||||
const ctx = makeContext(true)
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
expect(teleportResumeMock).toHaveBeenCalledWith(
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(resumeMockFn).toHaveBeenCalledWith(
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
mockLog,
|
||||
'slash_command_session_id',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_resume_session')
|
||||
expect(events).toContain('tengu_teleport_first_message_success')
|
||||
})
|
||||
|
||||
test('progress callback is invoked during teleportResumeCodeSession (line 225)', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(
|
||||
(_id: string, onProgress?: (stage: string) => void) => {
|
||||
onProgress?.('fetching_session')
|
||||
return Promise.resolve({ log: [], branch: 'main' })
|
||||
},
|
||||
)
|
||||
const ctx = makeContext(true)
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
expect(resumeMockFn).toHaveBeenCalled()
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_resume_session')
|
||||
})
|
||||
|
||||
test('teleportResumeCodeSession throws not-found error → fires session_not_found_ event', async () => {
|
||||
teleportResumeMock.mockImplementation(() =>
|
||||
Promise.reject(new Error('Session not found')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Teleport failed/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_session_not_found_')
|
||||
})
|
||||
|
||||
test('teleportResumeCodeSession throws repo mismatch → fires repo_mismatch event', async () => {
|
||||
teleportResumeMock.mockImplementation(() =>
|
||||
Promise.reject(new Error('repo mismatch: expected acme/foo')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_repo_mismatch_sessions_api')
|
||||
})
|
||||
|
||||
test('git dir error → fires tengu_teleport_error_repo_not_in_git_dir_ event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('not in git directory: /tmp/test')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain(
|
||||
'tengu_teleport_error_repo_not_in_git_dir_sessions_api',
|
||||
)
|
||||
})
|
||||
|
||||
test('cancelled error → fires tengu_teleport_cancelled event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('operation was cancelled')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_cancelled')
|
||||
})
|
||||
|
||||
test('token/unauthorized error → fires bad_token event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('401 unauthorized: bad token')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_token')
|
||||
})
|
||||
|
||||
test('status/4xx error → fires bad_status event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('500 internal server error bad status')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_status')
|
||||
})
|
||||
|
||||
test('valid session id without context.resume → fallback message', async () => {
|
||||
const ctx = makeContext(false) // no resume callback
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/did not provide a resume callback/)
|
||||
})
|
||||
|
||||
test('valid session id without context.resume + print mode → success message', async () => {
|
||||
const ctx = makeContext(false)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
ctx,
|
||||
'--print 12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(typeof firstArg).toBe('string')
|
||||
})
|
||||
|
||||
test('log not found after resume → fallback message', async () => {
|
||||
getLastSessionLogMock.mockImplementation(() =>
|
||||
Promise.resolve(null as unknown as LogOption),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/local log was not found/)
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
23
src/commands/teleport/index.ts
Normal file
23
src/commands/teleport/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
|
||||
const teleport: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'teleport',
|
||||
// Official v2.1.123 advertises alias `tp` (reverse-engineered from
|
||||
// claude.exe: `name:"teleport",aliases:["tp"]`). Keeping it for parity.
|
||||
aliases: ['tp'],
|
||||
description: 'Resume a Claude Code session from claude.ai',
|
||||
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
|
||||
argumentHint: 'SESSION_ID',
|
||||
isHidden: false,
|
||||
isEnabled: () => true,
|
||||
bridgeSafe: false,
|
||||
getBridgeInvocationError: (_args: string) =>
|
||||
'teleport resumes the REPL and is not bridge-safe',
|
||||
load: async () => {
|
||||
const m = await import('./launchTeleport.js')
|
||||
return { call: m.callTeleport }
|
||||
},
|
||||
}
|
||||
|
||||
export default teleport
|
||||
314
src/commands/teleport/launchTeleport.ts
Normal file
314
src/commands/teleport/launchTeleport.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { UUID } from 'node:crypto'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
import { getLastSessionLog } from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
teleportResumeCodeSession,
|
||||
validateGitState,
|
||||
} from '../../utils/teleport.js'
|
||||
import { fetchCodeSessionsFromSessionsAPI } from '../../utils/teleport/api.js'
|
||||
|
||||
// Minimum length for a UUID-like session ID (8 hex chars with dashes allowed)
|
||||
const SESSION_ID_MIN_LENGTH = 8
|
||||
|
||||
// Maximum sessions to display in the interactive picker
|
||||
const PICKER_PAGE_CAP = 20
|
||||
|
||||
function meta(
|
||||
s: string,
|
||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||
return s as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
export type TeleportProgressStep =
|
||||
| 'fetch'
|
||||
| 'validate'
|
||||
| 'resume'
|
||||
| 'ready'
|
||||
| 'error'
|
||||
|
||||
/**
|
||||
* Formats a sessions list as a text picker (no interactive UI in headless mode).
|
||||
* Returns a prompt the user can copy a session ID from.
|
||||
*/
|
||||
function formatSessionsPicker(
|
||||
sessions: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}>,
|
||||
): string {
|
||||
const rows = sessions.slice(0, PICKER_PAGE_CAP).map((s, i) => {
|
||||
const idx = String(i + 1).padStart(2)
|
||||
const title = s.title.slice(0, 50).padEnd(50)
|
||||
const status = s.status.padEnd(14)
|
||||
const created = s.created_at.slice(0, 10)
|
||||
return ` ${idx}. ${title} ${status} ${created} id=${s.id}`
|
||||
})
|
||||
return [
|
||||
'## Available sessions (most recent first)',
|
||||
'',
|
||||
...rows,
|
||||
'',
|
||||
'Run `/teleport <session-id>` to resume a session.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* /teleport [session-id]
|
||||
*
|
||||
* Without session-id: fetches the user's session list from the Sessions API
|
||||
* and renders an interactive picker (or text list in headless mode).
|
||||
*
|
||||
* With session-id:
|
||||
* 1. Validates local git state (must be clean)
|
||||
* 2. Fetches session logs + branch via teleportResumeCodeSession()
|
||||
* 3. Looks up the session LogOption by ID
|
||||
* 4. Hands off to the REPL via context.resume()
|
||||
*
|
||||
* Telemetry coverage:
|
||||
* - tengu_teleport_started
|
||||
* - tengu_teleport_events_fetch_fail
|
||||
* - tengu_teleport_page_cap
|
||||
* - tengu_teleport_source_decision
|
||||
* - tengu_teleport_resume_session
|
||||
* - tengu_teleport_first_message_success
|
||||
* - tengu_teleport_first_message_error
|
||||
* - tengu_teleport_failed
|
||||
* - tengu_teleport_cancelled
|
||||
* - tengu_teleport_null
|
||||
* - tengu_teleport_errors_detected
|
||||
* - tengu_teleport_errors_resolved
|
||||
* - tengu_teleport_error_session_not_found_
|
||||
* - tengu_teleport_error_repo_mismatch_sessions_api
|
||||
* - tengu_teleport_error_repo_not_in_git_dir_sessions_api
|
||||
* - tengu_teleport_error_bad_token
|
||||
* - tengu_teleport_error_bad_status
|
||||
*/
|
||||
export const callTeleport: LocalJSXCommandCall = async (
|
||||
onDone,
|
||||
context,
|
||||
args,
|
||||
) => {
|
||||
const rawArgs = args.trim()
|
||||
// --print flag: headless / non-interactive output
|
||||
const isPrintMode = rawArgs === '--print' || rawArgs.startsWith('--print ')
|
||||
const sessionId = isPrintMode
|
||||
? rawArgs.replace(/^--print\s*/, '').trim()
|
||||
: rawArgs
|
||||
|
||||
logEvent('tengu_teleport_started', {
|
||||
has_session_id: meta(sessionId ? 'true' : 'false'),
|
||||
})
|
||||
|
||||
// ── No session ID: interactive picker ──
|
||||
if (!sessionId) {
|
||||
logEvent('tengu_teleport_source_decision', {
|
||||
source: meta('sessions_api'),
|
||||
})
|
||||
|
||||
let sessions: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}>
|
||||
try {
|
||||
const raw = await fetchCodeSessionsFromSessionsAPI()
|
||||
sessions = raw.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title ?? 'Untitled',
|
||||
status: (s.status ?? 'unknown') as string,
|
||||
created_at: s.created_at ?? '',
|
||||
}))
|
||||
} catch (fetchErr: unknown) {
|
||||
const msg =
|
||||
fetchErr instanceof Error ? fetchErr.message : String(fetchErr)
|
||||
|
||||
if (/forbidden|401|403/i.test(msg)) {
|
||||
logEvent('tengu_teleport_events_fetch_forbidden', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
'Teleport: permission denied fetching sessions. Check your OAuth token (`claude auth status`).',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (/not found|404/i.test(msg)) {
|
||||
logEvent('tengu_teleport_events_fetch_not_found', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
'Teleport: sessions endpoint returned 404. The Sessions API may not be available for your account.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (/token|unauthorized/i.test(msg)) {
|
||||
logEvent('tengu_teleport_error_bad_token', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
`Teleport: authentication error — ${msg}. Try \`claude auth login\`.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_events_fetch_fail', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
`Teleport: failed to fetch sessions — ${msg}.\nUsage: /teleport SESSION_ID`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
logEvent('tengu_teleport_null', {})
|
||||
onDone(
|
||||
'No active sessions found on claude.ai/code.\nStart a new session at https://claude.ai/code',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (sessions.length >= PICKER_PAGE_CAP) {
|
||||
logEvent('tengu_teleport_page_cap', {
|
||||
count: meta(String(sessions.length)),
|
||||
})
|
||||
}
|
||||
|
||||
const pickerText = formatSessionsPicker(sessions)
|
||||
|
||||
if (isPrintMode) {
|
||||
onDone(pickerText, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Interactive context: display the list and prompt user to run with an ID.
|
||||
// A full Ink <SelectInput> picker requires an event loop that isn't safely
|
||||
// available from all command contexts; text list is the portable fallback.
|
||||
onDone(pickerText, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Basic format guard ──
|
||||
if (
|
||||
sessionId.length < SESSION_ID_MIN_LENGTH ||
|
||||
!/^[0-9a-f-]{8,}$/i.test(sessionId)
|
||||
) {
|
||||
logEvent('tengu_teleport_error_bad_status', {
|
||||
error: meta(`invalid_session_id: ${sessionId.slice(0, 40)}`),
|
||||
})
|
||||
onDone(
|
||||
`Invalid session id "${sessionId}". Expected a UUID-like string (e.g. 12345678-abcd-...).`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_source_decision', { source: meta('explicit_id') })
|
||||
|
||||
// ── Progress tracker (internal, no Ink rendering needed) ──
|
||||
const steps: TeleportProgressStep[] = []
|
||||
const recordStep = (step: TeleportProgressStep) => {
|
||||
steps.push(step)
|
||||
}
|
||||
|
||||
// ── Git state validation ──
|
||||
recordStep('validate')
|
||||
try {
|
||||
await validateGitState()
|
||||
} catch (gErr: unknown) {
|
||||
const msg = gErr instanceof Error ? gErr.message : String(gErr)
|
||||
logEvent('tengu_teleport_errors_detected', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(`Cannot teleport: ${msg}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Resume session ──
|
||||
recordStep('resume')
|
||||
try {
|
||||
let lastProgress = ''
|
||||
|
||||
await teleportResumeCodeSession(sessionId, stage => {
|
||||
lastProgress = String(stage)
|
||||
})
|
||||
|
||||
logEvent('tengu_teleport_resume_session', {
|
||||
stage: meta(lastProgress),
|
||||
})
|
||||
|
||||
recordStep('ready')
|
||||
|
||||
if (!context.resume) {
|
||||
logEvent('tengu_teleport_null', {})
|
||||
// resume callback unavailable (e.g. non-interactive context)
|
||||
if (isPrintMode) {
|
||||
onDone(`Session ${sessionId} fetched successfully.`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
onDone(
|
||||
`Teleport resume succeeded for ${sessionId}, but the REPL did not provide a resume callback.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Look up the session log so we can pass it to context.resume().
|
||||
recordStep('fetch')
|
||||
const log: LogOption | null = await getLastSessionLog(sessionId as UUID)
|
||||
if (!log) {
|
||||
logEvent('tengu_teleport_errors_detected', {
|
||||
error: meta('log_not_found_after_resume'),
|
||||
})
|
||||
onDone(
|
||||
`Teleport fetched session ${sessionId} but the local log was not found. Try /resume ${sessionId} manually.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_errors_resolved', {})
|
||||
await context.resume(sessionId as UUID, log, 'slash_command_session_id')
|
||||
logEvent('tengu_teleport_first_message_success', {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Map error message content to specific telemetry event names
|
||||
let evt = 'tengu_teleport_failed'
|
||||
if (/not found/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_session_not_found_'
|
||||
} else if (/repo.*mismatch/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_repo_mismatch_sessions_api'
|
||||
} else if (/not in.*git|git.*dir/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_repo_not_in_git_dir_sessions_api'
|
||||
} else if (/cancelled|aborted/i.test(msg)) {
|
||||
evt = 'tengu_teleport_cancelled'
|
||||
} else if (/token|unauthorized|401/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_bad_token'
|
||||
} else if (/status|4\d\d|5\d\d/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_bad_status'
|
||||
}
|
||||
|
||||
logEvent(evt, { error: meta(msg.slice(0, 200)) })
|
||||
logEvent('tengu_teleport_first_message_error', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(`Teleport failed: ${msg}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user