@tduyng/pi-cursor 0.0.1 → 0.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/README.md CHANGED
@@ -1,25 +1,21 @@
1
1
  # pi-cursor
2
2
 
3
- Cursor models in Pi through the official Cursor SDK. Pi keeps its own system prompt, tools, approvals, compaction and session tree. Cursor is only the authenticated model transport.
3
+ Cursor models in pi, through the official Cursor SDK. Pi keeps the prompt, the tools, the approvals, the compaction, and the session tree.
4
4
 
5
- No Cursor CLI is installed or called. Login is the SDK browser flow, which mints a stored Cursor API key under `~/.cursor/sdk/auth.json`.
5
+ ## What it costs
6
6
 
7
- The provider id is `cursor`, and model ids are Cursor's own ids. `/model cursor/gpt-5.5` and `/model cursor/claude-opus-4-8` work as written.
7
+ No tools, no prompt block, no schema. It is a model provider, so it adds nothing to the context and nothing to the cold start.
8
+
9
+ What it saves on the wire is the replay. A normal turn sends only the new user message, a tool round sends nothing, and a restarted pi process resumes the same Cursor agent instead of replaying the conversation.
8
10
 
9
11
  ## Requirements
10
12
 
11
13
  - Pi 1.4 or newer.
12
14
  - Node 24 or newer.
13
15
  - A Cursor account.
14
- - The extension dependency `@cursor/sdk` 1.0.40, installed by the workspace.
16
+ - `@cursor/sdk` 1.0.40, installed by the workspace.
15
17
 
16
- ## Install
17
-
18
- ```sh
19
- pi install /path/to/extensions/pi-cursor
20
- ```
21
-
22
- In this repository the workspace already registers the extension in the root `package.json`.
18
+ No Cursor CLI is installed or called. Login is the SDK browser flow, which mints a stored Cursor key under `~/.cursor/sdk/auth.json`.
23
19
 
24
20
  ## Login
25
21
 
@@ -27,11 +23,11 @@ In this repository the workspace already registers the extension in the root `pa
27
23
  /cursor login
28
24
  ```
29
25
 
30
- The SDK opens the Cursor login page and mints a key when the browser flow finishes. If the browser does not open, the command prints the URL. `/cursor status` shows the account and `/cursor logout` removes the stored key.
26
+ The SDK opens the Cursor login page and mints a key when the flow finishes. If the browser does not open, the command prints the URL. `/cursor status` shows the account and `/cursor logout` removes the stored key.
31
27
 
32
- `CURSOR_API_KEY` overrides the stored login. The key is read by the SDK, not copied into Pi's `auth.json`.
28
+ `CURSOR_API_KEY` overrides the stored login. The key is read by the SDK and not copied into pi's `auth.json`.
33
29
 
34
- Cursor bills SDK traffic under its own terms. This extension cannot tell you whether a run is plan-included, so check the Cursor dashboard for the account's SDK usage.
30
+ Cursor bills SDK traffic under its own terms, and this extension cannot tell you whether a run is plan-included. Check the Cursor dashboard for the account's SDK usage.
35
31
 
36
32
  ## Use
37
33
 
@@ -39,50 +35,84 @@ Cursor bills SDK traffic under its own terms. This extension cannot tell you whe
39
35
  /model cursor/gpt-5.5
40
36
  ```
41
37
 
42
- The first model list is a small fallback so `/model` is usable before login. After login, or on the next session start, the extension reads `Cursor.models.list()` and registers every model the account can reach. `/cursor models` prints them.
38
+ The provider id is `cursor` and model ids are Cursor's own. The list comes from `Cursor.models.list()`, cached after the last successful read in `~/.pi/agent/pi-cursor-models.json` at mode 0600 and keyed by the credential. Startup registers the cache with no network call, session start refreshes it, and a small built-in list is the fallback.
39
+
40
+ A model that offers several context windows is registered once per window: the plain id uses the smallest one, and `cursor/gpt-5.5@1m` asks for 1M. Cursor bills the larger windows as Max Mode, so the small default is the cheap one. `/cursor models` prints them.
41
+
42
+ `/cursor fast on` adds Cursor's `fast` parameter to every model that exposes it. It is a billing choice, so it stays off until you ask. `/cursor status` shows the current setting.
43
43
 
44
44
  ## How it works
45
45
 
46
- | Pi | Cursor SDK |
47
- | ------------------------- | ------------------------------------------------------------------------------------ |
48
- | Pi system prompt | `AgentOptions.systemPrompt`, replaces the Cursor harness prompt |
49
- | Pi tools | `LocalAgentOptions.customTools`, one in-process callback per Pi tool |
50
- | Cursor built-ins | `AgentOptions.tools: ['mcp']`, so only Pi's custom tools exist |
51
- | Cursor rules, skills, MCP | `local.settingSources: []` |
52
- | Pi tool execution | the custom tool callback parks, Pi runs the tool, the next provider call resolves it |
46
+ | Pi | Cursor SDK |
47
+ | --- | --- |
48
+ | Pi system prompt | `AgentOptions.systemPrompt`, replacing the Cursor harness prompt |
49
+ | Pi tools | `LocalAgentOptions.customTools`, one in-process callback per tool |
50
+ | Cursor built-ins | `AgentOptions.tools: ['mcp']`, so only pi's custom tools exist |
51
+ | Cursor rules, skills, MCP | `local.settingSources: []` |
52
+ | Pi tool execution | The callback parks, pi runs the tool, the next provider call resolves it |
53
+
54
+ A tool call does not end the Cursor run. The callback waits, pi executes the named tool through its normal pipeline, and the next `streamSimple` call resolves the callback with the result. The same run continues, so the model sees the result in the same conversation.
55
+
56
+ A message you steer in while a tool runs is delivered with `Run.steer`, so the model reads it before its next step. Images attached to such a message become a one-line note, because steering carries text only.
57
+
58
+ One agent runs per pi session. The agent is replaced only when the prompt, tool set, or working directory changes, or when the pi history diverges, and that replacement replays the conversation once as a single message.
59
+
60
+ A restarted pi process resumes the same agent instead of replaying. At the end of every clean turn the extension records the agent id with the working folder, prompt and tool hashes, the pi message count, and its own hash of that prefix, in `~/.pi/agent/pi-cursor-agents.json`. The next process calls `Agent.resume` when all of those still match and the pending turn is a single new user message. A stale id, a diverged fork, or a turn killed mid tool round falls back to the replay. The ledger keeps the last 20 sessions, and `/cursor clear`, `/cursor login`, and `/cursor logout` delete it, so those commands really do start from a fresh agent.
61
+
62
+ There is no HTTP MCP server, no Hono server, and no tool-name rewriting: the SDK invokes the callback directly.
63
+
64
+ ## What the wire costs
53
65
 
54
- A tool call does not end the Cursor run. The callback waits, Pi executes the named tool through its normal pipeline, and the next `streamSimple` call resolves the callback with the result. The same Cursor run continues, so the model sees the tool result in the same conversation.
66
+ Measured on the account this extension was built on, with gpt-5.5 and auto-smart, thinking off:
55
67
 
56
- One Cursor agent runs per Pi session. A normal turn sends only the new user message. A tool round resolves parked callbacks and sends nothing. The agent is only replaced when Pi's prompt, tool set or working directory changes, or when the Pi history diverges. That replacement replays the existing Pi conversation once as a single message.
68
+ | Call | Input tokens | Wall time |
69
+ | --- | --- | --- |
70
+ | First call of an agent | 11.9k fresh plus 2.6k cached | 4.8 to 5.5s |
71
+ | Plain follow-up turn | 12.9k fresh plus 12.7k cached | 2.2s |
72
+ | Call after a tool round | 36.9k fresh plus 36.0k cached | 4.4 to 13s |
57
73
 
58
- There is no HTTP MCP server, no Hono server and no tool-name rewriting. Cursor's SDK invokes the callback directly.
74
+ The tool-round number is Cursor's, not the tool result. The SDK re-prefills its own context around the custom tool callback, so one tool call costs about three plain calls. A turn that ends in a tool call is the slow turn, and there is no pi-side switch for it. The same 32k fresh appears with an empty working directory, so it is not workspace scanning. Fewer, wider tool calls per turn is the lever that exists.
59
75
 
60
- ## Token and cache behavior
76
+ Thinking levels map to Cursor model parameters: `thinking` and `effort` for Claude models, `reasoning` for GPT, `effort` for Grok, from the live catalog.
61
77
 
62
- Cursor's backend owns its own conversation and cache. Pi does not replay a linear conversation on every turn, and it does not run a second agent for tool rounds. The Pi system prompt is stable for the lifetime of an agent, and the tool list is stable too. A divergence is the only case that pays a replay.
78
+ ## Limits
63
79
 
64
- Thinking levels map to Cursor model parameters. For Claude models the extension sets both `thinking` and `effort`. For GPT models it sets `reasoning`. For Grok it sets `effort`. The mapping comes from the live model catalog.
80
+ - Cursor owns the agent loop and its context management, so pi's compaction does not reach inside a live agent.
65
81
 
66
- ## Limitations
82
+ - `AgentOptions.systemPrompt` is server-gated. A rejected prompt comes back as a failed run about 20 seconds in, not from `Agent.create`, so the extension retries that turn with the prompt prepended to the first message.
67
83
 
68
- - Cursor owns the agent loop and its context management. Pi's compaction does not reach inside a live Cursor agent.
69
- - `AgentOptions.systemPrompt` is server-gated. If Cursor rejects it, the extension recreates the agent without it and prepends Pi's prompt to the first message. That fallback works but spends more tokens because Cursor's harness prompt remains.
70
- - Usage tokens come from Cursor's `turn-ended` events. Pi shows a zero money cost because Cursor bills outside Pi.
71
- - Images work in new messages. Replayed history carries images without their original message labels.
72
- - A model id that Cursor no longer serves fails at `send`, not at model selection.
73
- - A long Pi tool run is held open by Cursor local runtime. The SDK does not document a timeout for custom tools.
74
- - The Cursor SDK is a 25 MB dependency with optional platform packages.
84
+ - The rejection is recorded as `noSystemPrompt` in `~/.pi/agent/pi-cursor.json`, and every later agent skips the doomed attempt. Clear it with `/cursor login` or by editing the file, and `/cursor status` shows which path is in use. The fallback works but spends more tokens, because the Cursor harness prompt remains.
75
85
 
76
- ## Configuration
86
+ - A failed run reaches pi as an error message, so an empty answer means a broken transport rather than a silent backend rejection.
87
+
88
+ - Usage tokens come from Cursor's `turn-ended` events plus the run's own total, and pi records each message's share, so the numbers add across a tool round. A message that ends in a tool call shows zero, because that step has not ended server side, and the next message carries both. Pi shows zero money cost because Cursor bills outside pi.
89
+
90
+ - Images ride with the message that carries them. A replay replaces earlier images with an `[image: mime]` placeholder and keeps only the pending message's images, so reattach one to ask about it again after a restart.
91
+
92
+ - A model id Cursor no longer serves fails at `send`, not at selection.
93
+
94
+ - A long pi tool run is held open by the Cursor local runtime, and the SDK documents no timeout for custom tools.
95
+
96
+ - The SDK is a 25 MB dependency with optional platform packages.
97
+
98
+ ## Configure
99
+
100
+ `CURSOR_API_KEY` overrides the stored login.
101
+
102
+ `~/.pi/agent/pi-cursor.json` holds the two preferences: `fast`, from `/cursor fast`, and `noSystemPrompt`, once Cursor has refused pi's prompt for this account.
103
+
104
+ ## Install
105
+
106
+ ```sh
107
+ pi install npm:@tduyng/pi-cursor
108
+ ```
77
109
 
78
- | Variable | Meaning |
79
- | ---------------- | ----------------------------------------------- |
80
- | `CURSOR_API_KEY` | Cursor API key. Overrides the stored SDK login. |
110
+ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-cursor`
81
111
 
82
112
  ## Tests
83
113
 
84
114
  ```sh
85
- bunx vitest run extensions/pi-cursor
115
+ bun test extensions/pi-cursor
86
116
  bun run typecheck
87
117
  bun run lint:anti-slop
88
118
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tduyng/pi-cursor",
3
- "version": "0.0.1",
4
- "description": "Use Cursor models in Pi through the official Cursor SDK, with Pi's own prompt and tools.",
3
+ "version": "0.0.3",
4
+ "description": "Cursor models in Pi through the official Cursor SDK: one warm agent per session, Pi's own prompt and tools, resumed instead of replayed.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
package/src/catalog.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ import type { ModelInfo } from './models.js'
9
+
10
+ const ParameterValue = t.object({ value: t.string(), displayName: t.optional(t.string()) })
11
+ const Parameter = t.object({
12
+ id: t.string(),
13
+ displayName: t.optional(t.string()),
14
+ values: t.array(ParameterValue)
15
+ })
16
+ const Variant = t.object({
17
+ params: t.array(t.object({ id: t.string(), value: t.string() })),
18
+ displayName: t.string(),
19
+ isDefault: t.optional(t.boolean())
20
+ })
21
+ const CachedModel = t.object({
22
+ id: t.string(),
23
+ displayName: t.string(),
24
+ parameters: t.optional(t.array(Parameter)),
25
+ variants: t.optional(t.array(Variant))
26
+ })
27
+ const CachedCatalog = t.object({ key: t.string(), models: t.array(CachedModel) })
28
+
29
+ const isCatalog = new Validator(CachedCatalog).isValidObject
30
+
31
+ export type KnownCatalog = Infer<typeof CachedCatalog>
32
+
33
+ function cachePath(): string {
34
+ return join(getAgentDir(), 'pi-cursor-models.json')
35
+ }
36
+
37
+ export function parseCatalog(text: string, key: string): ModelInfo[] | undefined {
38
+ try {
39
+ const parsed: unknown = JSON.parse(text)
40
+ if (!isCatalog(parsed) || parsed.key !== key) return undefined
41
+ return parsed.models
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ export function readCatalog(key: string): ModelInfo[] | undefined {
48
+ try {
49
+ return parseCatalog(readFileSync(cachePath(), 'utf8'), key)
50
+ } catch {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ export function writeCatalog(key: string, models: ModelInfo[]): void {
56
+ const path = cachePath()
57
+ const temp = `${path}.${process.pid}.tmp`
58
+ const catalog: KnownCatalog = {
59
+ key,
60
+ models: models.map((model) => ({
61
+ id: model.id,
62
+ displayName: model.displayName,
63
+ parameters: model.parameters,
64
+ variants: model.variants
65
+ }))
66
+ }
67
+ writeFileSync(temp, JSON.stringify(catalog), { mode: 0o600 })
68
+ renameSync(temp, path)
69
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,10 @@ import type { SdkLoginOptions } from '@cursor/sdk'
2
2
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
3
3
 
4
4
  import { fallbackModels, rememberModels, toPiModels, type PiModelEntry } from './models.js'
5
+ import { readCatalog, writeCatalog } from './catalog.js'
5
6
  import { cursorApiKey, cursorSdk } from './sdk.js'
7
+ import { dropAllRecords } from './resume.js'
8
+ import { noSystemPrompt, patchState } from './state.js'
6
9
  import { allSessions, dropSessions, sweep } from './session.js'
7
10
  import { createRuntime, streamCursor } from './turn.js'
8
11
 
@@ -12,7 +15,10 @@ const IDLE_MS = 20 * 60_000
12
15
 
13
16
  export default function piCursor(pi: ExtensionAPI): void {
14
17
  const runtime = createRuntime()
15
- let models: PiModelEntry[] = fallbackModels()
18
+ const catalogKey = () => cursorApiKey() ?? 'stored-login'
19
+ const cached = readCatalog(catalogKey())
20
+ let models: PiModelEntry[] = cached && cached.length > 0 ? toPiModels(cached) : fallbackModels()
21
+ if (cached && cached.length > 0) rememberModels(cached)
16
22
  let refreshing: Promise<PiModelEntry[]> | undefined
17
23
  let refreshed = false
18
24
  let sweeper: ReturnType<typeof setInterval> | undefined
@@ -36,8 +42,9 @@ export default function piCursor(pi: ExtensionAPI): void {
36
42
  const { Cursor } = await cursorSdk()
37
43
  const items = await Cursor.models.list({ apiKey: cursorApiKey() })
38
44
  rememberModels(items)
45
+ writeCatalog(catalogKey(), items)
39
46
  const next = toPiModels(items)
40
- if (next.length > 0) register(next)
47
+ if (next.length > 0) models = next
41
48
  return models
42
49
  } catch {
43
50
  return models
@@ -82,7 +89,9 @@ export default function piCursor(pi: ExtensionAPI): void {
82
89
  }
83
90
  if (ctx.signal) options.signal = ctx.signal
84
91
  const result = await Cursor.auth.login(options)
85
- await refresh()
92
+ patchState({ noSystemPrompt: false })
93
+ dropAllRecords()
94
+ register(await refresh())
86
95
  await show(
87
96
  `Cursor: logged in${result.email ? ` as ${result.email}` : ''}. ${models.length} model(s) available.`
88
97
  )
@@ -97,6 +106,7 @@ export default function piCursor(pi: ExtensionAPI): void {
97
106
  { value: 'logout', label: 'logout', description: 'forget the SDK credential' },
98
107
  { value: 'models', label: 'models', description: 'every Cursor model the provider exposes' },
99
108
  { value: 'clear', label: 'clear', description: 'close every warm Cursor agent' },
109
+ { value: 'fast', label: 'fast', description: 'toggle cursor fast mode: fast on|off' },
100
110
  { value: 'help', label: 'help', description: 'usage' }
101
111
  ].filter((item) => item.value.startsWith(prefix.trim())),
102
112
  handler: async (args: string, ctx: ExtensionCommandContext) => {
@@ -108,6 +118,7 @@ export default function piCursor(pi: ExtensionAPI): void {
108
118
  if (sub === 'logout') {
109
119
  const { Cursor } = await cursorSdk()
110
120
  await Cursor.auth.logout()
121
+ dropAllRecords()
111
122
  rememberModels([])
112
123
  register(fallbackModels())
113
124
  await show('Cursor: logged out. The SDK credential is gone.')
@@ -115,9 +126,21 @@ export default function piCursor(pi: ExtensionAPI): void {
115
126
  }
116
127
  if (sub === 'clear') {
117
128
  dropSessions(runtime.sessions)
129
+ dropAllRecords()
118
130
  await show('Cursor: closed every warm agent.')
119
131
  return
120
132
  }
133
+ if (sub === 'fast') {
134
+ const value = args.trim().split(/\s+/).filter(Boolean)[1]
135
+ if (value !== 'on' && value !== 'off') {
136
+ await show(`Cursor fast mode is ${runtime.fast ? 'on' : 'off'}. Use /cursor fast on|off.`)
137
+ return
138
+ }
139
+ runtime.fast = value === 'on'
140
+ patchState({ fast: runtime.fast })
141
+ await show(`Cursor fast mode ${runtime.fast ? 'on' : 'off'} for models that expose it.`)
142
+ return
143
+ }
121
144
  if (sub === 'models') {
122
145
  await show(
123
146
  [
@@ -130,12 +153,13 @@ export default function piCursor(pi: ExtensionAPI): void {
130
153
  if (sub === 'help') {
131
154
  await show(
132
155
  [
133
- 'Usage: /cursor [status|login|logout|models|clear|help]',
156
+ 'Usage: /cursor [status|login|logout|models|clear|fast|help]',
134
157
  '',
135
158
  'Login uses the official Cursor SDK browser flow and mints a stored API key.',
136
159
  'No Cursor CLI is installed or called.',
137
160
  'Pick a model with /model, for example cursor/gpt-5.5.',
138
- 'CURSOR_API_KEY overrides the stored SDK login.'
161
+ 'CURSOR_API_KEY overrides the stored SDK login.',
162
+ 'Models default to their smallest context window. Pick /model cursor/gpt-5.5@1m for the 1M one.',
139
163
  ].join('\n')
140
164
  )
141
165
  return
@@ -157,6 +181,12 @@ export default function piCursor(pi: ExtensionAPI): void {
157
181
  `credential: ${cursorApiKey() ? 'CURSOR_API_KEY' : 'stored SDK login'}`,
158
182
  `models: ${models.length}`,
159
183
  `live agents: ${sessions.length}, parked tool calls: ${parked}`,
184
+ `system prompt: ${
185
+ noSystemPrompt()
186
+ ? 'not enabled for this account, Pi prompt rides in the first message'
187
+ : 'sent as AgentOptions.systemPrompt'
188
+ }`,
189
+ `fast mode: ${runtime.fast ? 'on' : 'off'}`,
160
190
  'prompt: Pi system prompt and Pi tools only; Cursor built-in tools disabled'
161
191
  ].join('\n')
162
192
  )
package/src/models.ts ADDED
@@ -0,0 +1,198 @@
1
+ import type { ModelListItem, ModelParameterDefinition, ModelParameterValue } from '@cursor/sdk'
2
+ import type { ModelThinkingLevel, ThinkingLevelMap } from '@earendil-works/pi-ai'
3
+
4
+ export type PiModelEntry = {
5
+ id: string
6
+ name: string
7
+ reasoning: boolean
8
+ thinkingLevelMap?: ThinkingLevelMap
9
+ input: ('text' | 'image')[]
10
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
11
+ contextWindow: number
12
+ maxTokens: number
13
+ }
14
+
15
+ export type ModelSelection = { id: string; params?: ModelParameterValue[] }
16
+
17
+ type ThinkingParams = { boolean?: ModelParameterDefinition; levels?: ModelParameterDefinition }
18
+
19
+ const LEVELS: ModelThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
20
+ export type ModelInfo = Pick<ModelListItem, 'id' | 'displayName' | 'parameters' | 'variants'>
21
+
22
+ const catalog = new Map<string, ModelInfo>()
23
+
24
+ const FALLBACK_MODELS: PiModelEntry[] = [
25
+ entry('auto-smart', 'Auto'),
26
+ entry('claude-opus-4-8', 'Claude Opus 4.8', 300_000),
27
+ entry('claude-sonnet-5-5', 'Claude Sonnet 5.5'),
28
+ entry('gpt-5.5', 'GPT-5.5', 272_000),
29
+ entry('grok-4.6', 'Grok 4.6', 256_000),
30
+ entry('composer-2.5', 'Composer 2.5'),
31
+ entry('gemini-3.7-flash', 'Gemini 3.7 Flash')
32
+ ]
33
+
34
+ function entry(id: string, name: string, contextWindow = 200_000): PiModelEntry {
35
+ return {
36
+ id,
37
+ name,
38
+ reasoning: false,
39
+ input: ['text', 'image'],
40
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
41
+ contextWindow,
42
+ maxTokens: 32_768
43
+ }
44
+ }
45
+
46
+ export function rememberModels(items: ModelInfo[]): void {
47
+ catalog.clear()
48
+ for (const item of items) catalog.set(item.id, item)
49
+ }
50
+
51
+ export function fallbackModels(): PiModelEntry[] {
52
+ return FALLBACK_MODELS.map((model) => ({ ...model }))
53
+ }
54
+
55
+ export function toPiModels(items: ModelInfo[]): PiModelEntry[] {
56
+ const entries: PiModelEntry[] = []
57
+ for (const item of items) {
58
+ const contexts = contextValues(item)
59
+ entries.push(toPiModel(item, undefined, contexts[0]))
60
+ for (const context of contexts.slice(1)) entries.push(toPiModel(item, context, context))
61
+ }
62
+ return entries
63
+ }
64
+
65
+ export type ModelIdParts = { base: string; context?: string }
66
+
67
+ export function parseModelId(id: string): ModelIdParts {
68
+ const match = /^(.+)@(\d+[km])$/.exec(id)
69
+ if (!match?.[1] || !match[2]) return { base: id }
70
+ return { base: match[1], context: match[2] }
71
+ }
72
+
73
+ function contextValues(item: ModelInfo): string[] {
74
+ const values = item.parameters?.find((candidate) => candidate.id === 'context')?.values.map((value) => value.value)
75
+ if (!values) return []
76
+ return values.toSorted((left, right) => contextSize(left) - contextSize(right))
77
+ }
78
+
79
+ function contextSize(context: string): number {
80
+ const match = /^(\d+)([km])$/.exec(context)
81
+ if (!match?.[1] || !match[2]) return Number.MAX_SAFE_INTEGER
82
+ const size = Number(match[1])
83
+ return match[2] === 'm' ? size * 1_000_000 : size * 1_000
84
+ }
85
+
86
+ export function contextWindowOf(id: string): number {
87
+ const { base, context } = parseModelId(id)
88
+ if (context) return contextSize(context)
89
+ return contextWindowFor(base)
90
+ }
91
+
92
+ function toPiModel(item: ModelInfo, suffix: string | undefined, window: string | undefined): PiModelEntry {
93
+ const params = thinkingParams(item)
94
+ const id = suffix ? `${item.id}@${suffix}` : item.id
95
+ const displayName = item.displayName || item.id
96
+ const name = suffix ? `${displayName} ${suffix.toUpperCase()}` : displayName
97
+ const model = entry(id, name, window ? contextSize(window) : contextWindowFor(item.id))
98
+ if (params) {
99
+ model.reasoning = true
100
+ model.thinkingLevelMap = thinkingLevelMap(params)
101
+ }
102
+ return model
103
+ }
104
+
105
+ function thinkingParams(item: ModelInfo): ThinkingParams | undefined {
106
+ const boolean = item.parameters?.find(
107
+ (candidate) =>
108
+ candidate.id === 'thinking' &&
109
+ candidate.values.length > 0 &&
110
+ candidate.values.every((value) => value.value === 'true' || value.value === 'false')
111
+ )
112
+ const levels =
113
+ item.parameters?.find((candidate) => candidate.id === 'effort' && candidate.values.length > 0) ??
114
+ item.parameters?.find((candidate) => candidate.id === 'reasoning' && candidate.values.length > 0)
115
+ if (!boolean && !levels) return undefined
116
+ return { boolean, levels }
117
+ }
118
+
119
+ function thinkingLevelMap(params: ThinkingParams): ThinkingLevelMap | undefined {
120
+ const map: ThinkingLevelMap = {}
121
+ let any = false
122
+ for (const level of LEVELS) {
123
+ let value: string | undefined
124
+ if (level === 'off') value = params.boolean ? 'false' : mapLevel(params.levels, level)
125
+ else if (params.levels) value = mapLevel(params.levels, level)
126
+ else value = params.boolean ? 'true' : undefined
127
+ map[level] = value ?? null
128
+ if (value) any = true
129
+ }
130
+ return any ? map : undefined
131
+ }
132
+
133
+ function mapLevel(param: ModelParameterDefinition | undefined, level: ModelThinkingLevel): string | undefined {
134
+ const values = param?.values.map((value) => value.value) ?? []
135
+ if (level === 'off') return values.find((value) => value === 'none' || value === 'false' || value === 'off')
136
+ if (level === 'minimal') {
137
+ return values.find((value) => value === 'minimal') ?? values.find((value) => value === 'low')
138
+ }
139
+ if (level === 'low') return values.find((value) => value === 'low')
140
+ if (level === 'medium') return values.find((value) => value === 'medium')
141
+ if (level === 'high') return values.find((value) => value === 'high')
142
+ if (level === 'xhigh') {
143
+ return values.find((value) => value === 'xhigh') ?? values.find((value) => value === 'extra-high')
144
+ }
145
+ return (
146
+ values.find((value) => value === 'max') ??
147
+ values.find((value) => value === 'xhigh') ??
148
+ values.find((value) => value === 'extra-high')
149
+ )
150
+ }
151
+
152
+ export function selectionFor(id: string, level: ModelThinkingLevel | undefined, fast = false): ModelSelection {
153
+ const { base, context } = parseModelId(id)
154
+ const item = catalog.get(base)
155
+ if (!item) return { id: base }
156
+ const variant = item.variants?.find((candidate) => candidate.isDefault) ?? item.variants?.[0]
157
+ const ordered = variant?.params.map((param) => param.id) ?? []
158
+ const values = new Map<string, string>()
159
+ for (const param of variant?.params ?? []) values.set(param.id, param.value)
160
+ const chosenContext = context ?? contextValues(item)[0]
161
+ if (chosenContext) {
162
+ values.set('context', chosenContext)
163
+ if (!ordered.includes('context')) ordered.push('context')
164
+ }
165
+ if (fast && item.parameters?.some((candidate) => candidate.id === 'fast')) {
166
+ values.set('fast', 'true')
167
+ if (!ordered.includes('fast')) ordered.push('fast')
168
+ }
169
+ const params = thinkingParams(item)
170
+ if (params && level) {
171
+ if (params.boolean) {
172
+ values.set(params.boolean.id, level === 'off' ? 'false' : 'true')
173
+ if (!ordered.includes(params.boolean.id)) ordered.push(params.boolean.id)
174
+ }
175
+ if (params.levels) {
176
+ const value = mapLevel(params.levels, level)
177
+ if (value) values.set(params.levels.id, value)
178
+ if (!ordered.includes(params.levels.id)) ordered.push(params.levels.id)
179
+ }
180
+ }
181
+ const result: ModelParameterValue[] = []
182
+ for (const key of ordered) {
183
+ const value = values.get(key)
184
+ if (value !== undefined) result.push({ id: key, value })
185
+ }
186
+ return result.length > 0 ? { id: base, params: result } : { id: base }
187
+ }
188
+
189
+ export function contextWindowFor(id: string): number {
190
+ if (id.startsWith('gpt-5')) return 272_000
191
+ if (id.startsWith('grok')) return 256_000
192
+ return 200_000
193
+ }
194
+
195
+ export function modelName(id: string): string {
196
+ const { base } = parseModelId(id)
197
+ return catalog.get(base)?.displayName ?? FALLBACK_MODELS.find((model) => model.id === base)?.name ?? id
198
+ }
package/src/resume.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ import type { SessionKey } from './session.js'
9
+ import type { Transcript, TurnMessage } from './transcript.js'
10
+
11
+ const ResumeRecord = t.object({
12
+ sessionId: t.string(),
13
+ agentId: t.string(),
14
+ cwd: t.string(),
15
+ toolsHash: t.string(),
16
+ count: t.number(),
17
+ chain: t.string(),
18
+ at: t.number()
19
+ })
20
+
21
+ const ResumeLedger = t.object({ records: t.array(ResumeRecord) })
22
+
23
+ const isLedger = new Validator(ResumeLedger).isValidObject
24
+
25
+ export type AgentRecord = Infer<typeof ResumeRecord>
26
+
27
+ const KEEP = 20
28
+
29
+ function ledgerPath(): string {
30
+ return join(getAgentDir(), 'pi-cursor-agents.json')
31
+ }
32
+
33
+ function readLedger(): AgentRecord[] {
34
+ try {
35
+ const parsed: unknown = JSON.parse(readFileSync(ledgerPath(), 'utf8'))
36
+ return isLedger(parsed) ? parsed.records : []
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ export function dropAllRecords(): void {
43
+ rmSync(ledgerPath(), { force: true })
44
+ }
45
+
46
+ export function readRecord(sessionId: string): AgentRecord | undefined {
47
+ return readLedger().findLast((record) => record.sessionId === sessionId)
48
+ }
49
+
50
+ export function writeRecord(record: AgentRecord): void {
51
+ const records = readLedger().filter((candidate) => candidate.sessionId !== record.sessionId)
52
+ records.push(record)
53
+ const path = ledgerPath()
54
+ const temp = `${path}.${process.pid}.tmp`
55
+ const kept = records.toSorted((left, right) => left.at - right.at).slice(-KEEP)
56
+ writeFileSync(temp, JSON.stringify({ records: kept }), { mode: 0o600 })
57
+ renameSync(temp, path)
58
+ }
59
+
60
+ export function dropRecord(sessionId: string): void {
61
+ const records = readLedger().filter((candidate) => candidate.sessionId !== sessionId)
62
+ const path = ledgerPath()
63
+ const temp = `${path}.${process.pid}.tmp`
64
+ writeFileSync(temp, JSON.stringify({ records }), { mode: 0o600 })
65
+ renameSync(temp, path)
66
+ }
67
+
68
+ export function resumeTail(
69
+ record: AgentRecord,
70
+ transcript: Transcript,
71
+ key: SessionKey
72
+ ): TurnMessage[] | undefined {
73
+ if (record.cwd !== key.cwd) return undefined
74
+ if (record.toolsHash !== key.toolsHash) return undefined
75
+ if (record.count >= transcript.chain.length) return undefined
76
+ if (transcript.chain[record.count] !== record.chain) return undefined
77
+ const tail = transcript.messages.slice(record.count)
78
+ if (tail.some((message) => message.role === 'assistant' || message.role === 'toolResult')) return undefined
79
+ if (!tail.some((message) => message.role === 'user')) return undefined
80
+ return tail
81
+ }
package/src/session.ts CHANGED
@@ -1,7 +1,17 @@
1
- import type { AgentOptions, Run, SDKAgent, SDKCustomTool, SDKCustomToolResult, TokenUsage } from '@cursor/sdk'
1
+ import type {
2
+ AgentOptions,
3
+ Run,
4
+ RunResult,
5
+ SDKAgent,
6
+ SDKCustomTool,
7
+ SDKCustomToolResult,
8
+ TokenUsage
9
+ } from '@cursor/sdk'
2
10
  import type { AssistantMessage, Tool, ToolResultMessage } from '@earendil-works/pi-ai'
11
+ import type { AgentRecord } from './resume.js'
3
12
 
4
13
  import { sha, type JsonObject } from './json.js'
14
+ import { noSystemPrompt, patchState } from './state.js'
5
15
  import { selectionFor, type ModelSelection } from './models.js'
6
16
  import { cursorApiKey, cursorSdk } from './sdk.js'
7
17
  import {
@@ -44,6 +54,7 @@ export type CursorSession = {
44
54
  chain: string
45
55
  count: number
46
56
  usage?: TokenUsage
57
+ reported?: TokenUsage
47
58
  toolCounter: number
48
59
  promptFallback?: boolean
49
60
  promptSent?: boolean
@@ -129,6 +140,7 @@ export function createSession(
129
140
  chain: transcript.chain[transcript.messages.length] ?? transcript.chain[0] ?? sha('prompt'),
130
141
  count: transcript.messages.length,
131
142
  toolCounter: 0,
143
+ promptFallback: noSystemPrompt(),
132
144
  busy: true,
133
145
  dead: false,
134
146
  lastUsed: Date.now()
@@ -178,23 +190,25 @@ export function updateChain(session: CursorSession, transcript: Transcript, outp
178
190
  session.lastUsed = Date.now()
179
191
  }
180
192
 
193
+ function agentOptions(session: CursorSession, model: string, systemPrompt: string | undefined): AgentOptions {
194
+ const options: AgentOptions = {
195
+ apiKey: cursorApiKey(),
196
+ model: selectionFor(model, undefined),
197
+ tools: ['mcp'],
198
+ local: {
199
+ cwd: session.key.cwd,
200
+ settingSources: [],
201
+ customTools: customTools(session)
202
+ }
203
+ }
204
+ if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
205
+ return options
206
+ }
207
+
181
208
  export async function ensureAgent(session: CursorSession, model: string): Promise<SDKAgent> {
182
209
  if (session.agent) return session.agent
183
210
  const { Agent } = await cursorSdk()
184
- const create = (systemPrompt: string | undefined) => {
185
- const options: AgentOptions = {
186
- apiKey: cursorApiKey(),
187
- model: selectionFor(model, undefined),
188
- tools: ['mcp'],
189
- local: {
190
- cwd: session.key.cwd,
191
- settingSources: [],
192
- customTools: customTools(session)
193
- }
194
- }
195
- if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
196
- return Agent.create(options)
197
- }
211
+ const create = (systemPrompt: string | undefined) => Agent.create(agentOptions(session, model, systemPrompt))
198
212
  try {
199
213
  session.agent = await create(session.promptFallback ? undefined : session.systemPrompt)
200
214
  return session.agent
@@ -203,6 +217,7 @@ export async function ensureAgent(session: CursorSession, model: string): Promis
203
217
  if (!session.promptFallback && /system.?prompt/i.test(message)) {
204
218
  session.promptFallback = true
205
219
  session.agent = await create(undefined)
220
+ patchState({ noSystemPrompt: true })
206
221
  return session.agent
207
222
  }
208
223
  if (/auth|api.?key|login|credential/i.test(message)) {
@@ -212,6 +227,32 @@ export async function ensureAgent(session: CursorSession, model: string): Promis
212
227
  }
213
228
  }
214
229
 
230
+ export async function resumeSession(
231
+ store: CursorSessionStore,
232
+ id: string,
233
+ key: SessionKey,
234
+ systemPrompt: string,
235
+ tools: Tool[],
236
+ transcript: Transcript,
237
+ model: string,
238
+ record: AgentRecord
239
+ ): Promise<CursorSession | undefined> {
240
+ const session = createSession(store, id, key, systemPrompt, tools, transcript)
241
+ session.promptFallback = noSystemPrompt()
242
+ session.promptSent = true
243
+ session.count = record.count
244
+ session.chain = record.chain
245
+ try {
246
+ const { Agent } = await cursorSdk()
247
+ const prompt = session.promptFallback ? undefined : systemPrompt
248
+ session.agent = await Agent.resume(record.agentId, agentOptions(session, model, prompt))
249
+ return session
250
+ } catch {
251
+ dropSession(store, session)
252
+ return undefined
253
+ }
254
+ }
255
+
215
256
  function customTools(session: CursorSession) {
216
257
  const tools: Record<string, SDKCustomTool> = {}
217
258
  for (const tool of session.tools) {
@@ -241,6 +282,7 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
241
282
  const payload = session.promptFallback && !session.promptSent ? withPreamble(input, session.systemPrompt) : input
242
283
  if (session.promptFallback) session.promptSent = true
243
284
  session.usage = undefined
285
+ session.reported = undefined
244
286
  let run: Run
245
287
  try {
246
288
  run = await agent.send(toSdkMessage(payload), {
@@ -249,22 +291,14 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
249
291
  if (update.type === 'text-delta') session.queue.push({ kind: 'text', text: update.text })
250
292
  else if (update.type === 'thinking-delta') session.queue.push({ kind: 'thinking', text: update.text })
251
293
  else if (update.type === 'turn-ended' && update.usage) {
252
- const usage = update.usage
253
- session.usage = {
254
- ...usage,
255
- totalTokens:
256
- usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
257
- }
294
+ session.usage = addUsage(session.usage, normalizeUsage(update.usage))
258
295
  }
259
296
  }
260
297
  })
261
298
  } catch (error) {
262
299
  const message = error instanceof Error ? error.message : String(error)
263
300
  if (!session.promptFallback && /system.?prompt/i.test(message)) {
264
- session.promptFallback = true
265
- session.agent?.close()
266
- session.agent = undefined
267
- await startRun(session, input, selection)
301
+ await restartWithoutPrompt(session, input, selection)
268
302
  return
269
303
  }
270
304
  throw error
@@ -272,8 +306,17 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
272
306
  session.run = run
273
307
  void run
274
308
  .wait()
275
- .then((result) => {
276
- if (!session.usage && result.usage) session.usage = result.usage
309
+ .then(async (result) => {
310
+ if (result.usage) session.usage = largerUsage(session.usage, normalizeUsage(result.usage))
311
+ const outcome = outcomeOf(result, session.promptFallback === true)
312
+ if (outcome.kind === 'retry') {
313
+ await restartWithoutPrompt(session, input, selection)
314
+ return
315
+ }
316
+ if (outcome.kind === 'error') {
317
+ session.queue.push({ kind: 'error', message: outcome.message })
318
+ return
319
+ }
277
320
  session.queue.push({ kind: 'done' })
278
321
  })
279
322
  .catch((error: Error) => {
@@ -281,6 +324,72 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
281
324
  })
282
325
  }
283
326
 
327
+ type ReportedUsage = Omit<TokenUsage, 'totalTokens'>
328
+
329
+ function normalizeUsage(usage: ReportedUsage): TokenUsage {
330
+ return {
331
+ ...usage,
332
+ totalTokens: usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
333
+ }
334
+ }
335
+
336
+ export function addUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
337
+ if (!current) return { ...next }
338
+ const reasoning = current.reasoningTokens
339
+ const extra = next.reasoningTokens
340
+ const total: TokenUsage = {
341
+ inputTokens: current.inputTokens + next.inputTokens,
342
+ outputTokens: current.outputTokens + next.outputTokens,
343
+ cacheReadTokens: current.cacheReadTokens + next.cacheReadTokens,
344
+ cacheWriteTokens: current.cacheWriteTokens + next.cacheWriteTokens,
345
+ totalTokens: current.totalTokens + next.totalTokens
346
+ }
347
+ if (reasoning !== undefined || extra !== undefined) total.reasoningTokens = (reasoning ?? 0) + (extra ?? 0)
348
+ return total
349
+ }
350
+
351
+ export function largerUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
352
+ if (!current || next.totalTokens > current.totalTokens) return { ...next }
353
+ return current
354
+ }
355
+
356
+ export function usageDelta(total: TokenUsage | undefined, reported: TokenUsage | undefined): TokenUsage | undefined {
357
+ if (!total) return undefined
358
+ if (!reported) return total
359
+ const delta: TokenUsage = {
360
+ inputTokens: Math.max(total.inputTokens - reported.inputTokens, 0),
361
+ outputTokens: Math.max(total.outputTokens - reported.outputTokens, 0),
362
+ cacheReadTokens: Math.max(total.cacheReadTokens - reported.cacheReadTokens, 0),
363
+ cacheWriteTokens: Math.max(total.cacheWriteTokens - reported.cacheWriteTokens, 0),
364
+ totalTokens: Math.max(total.totalTokens - reported.totalTokens, 0)
365
+ }
366
+ if (total.reasoningTokens !== undefined) {
367
+ delta.reasoningTokens = Math.max(total.reasoningTokens - (reported.reasoningTokens ?? 0), 0)
368
+ }
369
+ return delta
370
+ }
371
+
372
+ export type RunOutcome = { kind: 'done' } | { kind: 'retry' } | { kind: 'error'; message: string }
373
+
374
+ export function outcomeOf(result: RunResult, promptFallback: boolean): RunOutcome {
375
+ if (result.status !== 'error') return { kind: 'done' }
376
+ const message = result.error?.message ?? 'the Cursor run failed'
377
+ if (!promptFallback && /system.?prompt/i.test(message)) return { kind: 'retry' }
378
+ return { kind: 'error', message }
379
+ }
380
+
381
+ async function restartWithoutPrompt(
382
+ session: CursorSession,
383
+ input: CursorInput,
384
+ selection: ModelSelection
385
+ ): Promise<void> {
386
+ session.promptFallback = true
387
+ session.agent?.close()
388
+ session.agent = undefined
389
+ await startRun(session, input, selection)
390
+ patchState({ noSystemPrompt: true })
391
+ }
392
+
284
393
  export function deliverToolResult(session: CursorSession, message: ToolResultMessage): void {
285
394
  const result = toolResultCustom(message)
286
395
  const resolve = session.parked.get(message.toolCallId)
package/src/state.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ const CursorPreferences = t.object({
9
+ noSystemPrompt: t.optional(t.boolean()),
10
+ fast: t.optional(t.boolean())
11
+ })
12
+
13
+ const isPreferences = new Validator(CursorPreferences).isValidObject
14
+
15
+ export type CursorState = Infer<typeof CursorPreferences>
16
+
17
+ function statePath(): string {
18
+ return join(getAgentDir(), 'pi-cursor.json')
19
+ }
20
+
21
+ export function readState(): CursorState {
22
+ try {
23
+ const parsed: unknown = JSON.parse(readFileSync(statePath(), 'utf8'))
24
+ return isPreferences(parsed) ? parsed : {}
25
+ } catch {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ export function patchState(patch: CursorState): void {
31
+ const path = statePath()
32
+ const temp = `${path}.${process.pid}.tmp`
33
+ writeFileSync(temp, JSON.stringify({ ...readState(), ...patch }), { mode: 0o600 })
34
+ renameSync(temp, path)
35
+ }
36
+
37
+ export function noSystemPrompt(): boolean {
38
+ return readState().noSystemPrompt === true
39
+ }
40
+
41
+ export function fastMode(): boolean {
42
+ return readState().fast === true
43
+ }
package/src/transcript.ts CHANGED
@@ -181,15 +181,13 @@ export function replayInput(messages: TurnMessage[]): CursorInput {
181
181
  if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
182
182
  }
183
183
 
184
+ const pending = messages.findLast((message) => message.role === 'user')
184
185
  const images: CursorImage[] = []
185
- for (const message of messages) {
186
- if (message.role !== 'user' || !Array.isArray(message.content)) continue
187
- for (const block of message.content) {
188
- if (block.type !== 'image') continue
189
- images.push(image(block))
186
+ if (pending && Array.isArray(pending.content)) {
187
+ for (const block of pending.content) {
188
+ if (block.type === 'image') images.push(image(block))
190
189
  }
191
190
  }
192
- if (images.length > 0) parts.push(`The replay includes ${images.length} image(s) from earlier user messages.`)
193
191
  return { text: parts.join('\n\n'), images }
194
192
  }
195
193
 
package/src/turn.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  } from '@earendil-works/pi-ai'
12
12
 
13
13
  import { sha } from './json.js'
14
+ import { fastMode } from './state.js'
15
+ import { dropRecord, readRecord, resumeTail, writeRecord } from './resume.js'
14
16
  import { selectionFor } from './models.js'
15
17
  import {
16
18
  attachSession,
@@ -19,8 +21,10 @@ import {
19
21
  deliverToolResult,
20
22
  dropSession,
21
23
  failMissingResults,
24
+ resumeSession,
22
25
  startRun,
23
26
  updateChain,
27
+ usageDelta,
24
28
  type Attached,
25
29
  type CursorSession,
26
30
  type CursorEvent,
@@ -29,10 +33,10 @@ import {
29
33
  } from './session.js'
30
34
  import { readTranscript, replayInput, systemPreamble, userInput, type CursorContext } from './transcript.js'
31
35
 
32
- export type CursorRuntime = { sessions: CursorSessionStore }
36
+ export type CursorRuntime = { sessions: CursorSessionStore; fast: boolean }
33
37
 
34
38
  export function createRuntime(): CursorRuntime {
35
- return { sessions: createSessionStore() }
39
+ return { sessions: createSessionStore(), fast: fastMode() }
36
40
  }
37
41
 
38
42
  const TOOL_GRACE_MS = 250
@@ -113,7 +117,7 @@ async function runTurn(
113
117
  .join('\n\n')
114
118
  const key: SessionKey = { systemPrompt, toolsHash: transcript.toolsHash, cwd }
115
119
  const sessionId = options?.sessionId ?? sha(systemPrompt)
116
- const selection = selectionFor(model.id, options?.reasoning)
120
+ const selection = selectionFor(model.id, options?.reasoning, runtime.fast)
117
121
  let attached: Attached | undefined = attachSession(runtime.sessions, transcript, key)
118
122
  if (attached?.session.dead) {
119
123
  dropSession(runtime.sessions, attached.session)
@@ -140,6 +144,14 @@ async function runTurn(
140
144
  }
141
145
  failMissingResults(active, seen)
142
146
  active.awaiting.clear()
147
+ const steering = userInput(rest)
148
+ if (steering.text.length > 0 && active.run?.steer) {
149
+ const note =
150
+ steering.images.length > 0
151
+ ? `${steering.text}\n[${steering.images.length} image(s) could not be injected mid-turn]`
152
+ : steering.text
153
+ await active.run.steer(note)
154
+ }
143
155
  } else {
144
156
  const input = userInput(rest)
145
157
  if (input.text.length === 0 && input.images.length === 0) {
@@ -151,9 +163,37 @@ async function runTurn(
151
163
  const previous = runtime.sessions.sessions.get(sessionId)
152
164
  if (previous?.busy) throw new Error('this Pi session already has a request in flight')
153
165
  if (previous) dropSession(runtime.sessions, previous)
154
- const created = createSession(runtime.sessions, sessionId, key, systemPrompt, transcript.tools, transcript)
155
- session = created
156
- await startRun(created, replayInput(transcript.messages), selection)
166
+ const record = options?.sessionId ? readRecord(options.sessionId) : undefined
167
+ const tail = record ? resumeTail(record, transcript, key) : undefined
168
+ const resumed =
169
+ record && tail
170
+ ? await resumeSession(
171
+ runtime.sessions,
172
+ sessionId,
173
+ key,
174
+ systemPrompt,
175
+ transcript.tools,
176
+ transcript,
177
+ model.id,
178
+ record
179
+ )
180
+ : undefined
181
+ if (resumed && tail) {
182
+ session = resumed
183
+ await startRun(resumed, userInput(tail), selection)
184
+ } else {
185
+ if (record && options?.sessionId) dropRecord(options.sessionId)
186
+ const created = createSession(
187
+ runtime.sessions,
188
+ sessionId,
189
+ key,
190
+ systemPrompt,
191
+ transcript.tools,
192
+ transcript
193
+ )
194
+ session = created
195
+ await startRun(created, replayInput(transcript.messages), selection)
196
+ }
157
197
  }
158
198
 
159
199
  const active = session
@@ -180,6 +220,18 @@ async function runTurn(
180
220
  active.abortCleanup?.()
181
221
  active.abortCleanup = undefined
182
222
  active.run = undefined
223
+ const agentId = active.agent?.agentId
224
+ if (options?.sessionId && agentId) {
225
+ writeRecord({
226
+ sessionId: options.sessionId,
227
+ agentId,
228
+ cwd: key.cwd,
229
+ toolsHash: key.toolsHash,
230
+ count: active.count,
231
+ chain: active.chain,
232
+ at: Date.now()
233
+ })
234
+ }
183
235
  }
184
236
 
185
237
  stream.push({ type: 'done', reason: step.reason, message: output })
@@ -196,15 +248,16 @@ async function runTurn(
196
248
  }
197
249
 
198
250
  function applyUsage(output: AssistantMessage, session: CursorSession): void {
199
- const usage = session.usage
200
- if (!usage) return
201
- output.usage.input = usage.inputTokens
202
- output.usage.output = usage.outputTokens
203
- output.usage.cacheRead = usage.cacheReadTokens
204
- output.usage.cacheWrite = usage.cacheWriteTokens
251
+ const delta = usageDelta(session.usage, session.reported)
252
+ if (!delta) return
253
+ session.reported = session.usage ? { ...session.usage } : undefined
254
+ output.usage.input = delta.inputTokens
255
+ output.usage.output = delta.outputTokens
256
+ output.usage.cacheRead = delta.cacheReadTokens
257
+ output.usage.cacheWrite = delta.cacheWriteTokens
205
258
  output.usage.totalTokens =
206
- usage.totalTokens || usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
207
- if (usage.reasoningTokens !== undefined) output.usage.reasoning = usage.reasoningTokens
259
+ delta.inputTokens + delta.outputTokens + delta.cacheReadTokens + delta.cacheWriteTokens
260
+ if (delta.reasoningTokens !== undefined) output.usage.reasoning = delta.reasoningTokens
208
261
  output.usage.cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
209
262
  }
210
263