@snack-ai/opencode 1.0.3 → 1.0.5
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 +28 -8
- package/README.pt-BR.md +28 -9
- package/package.json +1 -1
- package/src/plugin.js +88 -124
- package/src/spool-writer.js +229 -0
package/README.md
CHANGED
|
@@ -12,7 +12,10 @@ afterwards.
|
|
|
12
12
|
|
|
13
13
|
That difference matters more than it sounds. Some things simply do not survive to disk — a prompt
|
|
14
14
|
the provider refuses outright can leave no durable trace in OpenCode's own database, and a refusal
|
|
15
|
-
SNACK cannot see is a refusal it cannot learn from. The plugin catches those as they happen
|
|
15
|
+
SNACK cannot see is a refusal it cannot learn from. The plugin catches those as they happen — when
|
|
16
|
+
OpenCode reports one with a status code. OpenCode `1.18.15` retries a 429 itself and reports the
|
|
17
|
+
retry only as the provider's free text, so for such a turn the plugin states no ending at all and
|
|
18
|
+
leaves it to SNACK's reading of the database.
|
|
16
19
|
|
|
17
20
|
It is deliberately tiny. It appends one line of JSON per event to a private file and gets out of the
|
|
18
21
|
way. It never opens a database, never imports the SNACK CLI, never phones anywhere, and never —
|
|
@@ -23,7 +26,7 @@ under any failure — gets between you and your prompt.
|
|
|
23
26
|
The SNACK CLI registers it in OpenCode's own configuration for you:
|
|
24
27
|
|
|
25
28
|
```bash
|
|
26
|
-
npm install -g @snack-ai/cli
|
|
29
|
+
npm install -g --allow-scripts=better-sqlite3 @snack-ai/cli # builds the SQLite driver; npm 12 skips it otherwise
|
|
27
30
|
snack setup opencode --install-plugin
|
|
28
31
|
```
|
|
29
32
|
|
|
@@ -42,11 +45,12 @@ metadata:
|
|
|
42
45
|
|
|
43
46
|
- which prompt and session it belongs to, by identifier;
|
|
44
47
|
- the provider and model, and when it happened;
|
|
45
|
-
- how it ended: completed, cancelled, an operational error, or an observed restriction and its
|
|
46
|
-
|
|
47
|
-
- token counts and cost as the provider reported them.
|
|
48
|
+
- how it ended: completed, cancelled, an operational error, or an observed restriction and its class
|
|
49
|
+
— once per prompt, and not at all for a turn OpenCode retried.
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
Token counts and cost are not among them: the schema has room for usage, but the plugin leaves it
|
|
52
|
+
empty and SNACK takes usage from OpenCode's database. There is no field for prompt text or response
|
|
53
|
+
text, and the schema refuses unknown fields outright.
|
|
50
54
|
|
|
51
55
|
With `--enable-prospective-analysis`, each prompt additionally carries a few non-semantic shape
|
|
52
56
|
features: an estimated token count, a bucketed line count, a bucketed count of fenced code blocks,
|
|
@@ -75,12 +79,22 @@ would carry an invented meaning downstream for as long as it lived.
|
|
|
75
79
|
|
|
76
80
|
Events are appended as NDJSON to segment files with `0600` permissions in a `0700` directory. Append
|
|
77
81
|
is the only write operation; nothing is ever rewritten in place, which is what makes a crash
|
|
78
|
-
mid-write recoverable rather than corrupting.
|
|
82
|
+
mid-write recoverable rather than corrupting. A write that fails is cut back to where it began, and
|
|
83
|
+
an append to a segment that ends mid-line starts on a fresh line, so a broken line never takes the
|
|
84
|
+
next event with it.
|
|
79
85
|
|
|
80
86
|
A line cut short by a crash is exactly what truncation recovery expects: the reader validates each
|
|
81
87
|
line, discards the incomplete one with a sanitized diagnostic, and keeps everything before it. The
|
|
82
88
|
count of refused records surfaces in `snack sync` as `rejected_invalid` rather than disappearing.
|
|
83
89
|
|
|
90
|
+
Each append takes a writer lock for the milliseconds it lasts. A lock older than two minutes was
|
|
91
|
+
abandoned, so the plugin and `snack sync` take it over whatever process id it names, and
|
|
92
|
+
`snack doctor` warns with `spool_lock:<alias>` while one is there. The takeover is atomic: two
|
|
93
|
+
writers that judge the same lock abandoned at once never both hold it, and a failed append is
|
|
94
|
+
truncated back only by a writer that still holds the lock. Age is read from the wall clock, so a
|
|
95
|
+
clock jump of more than two minutes, or a laptop resumed mid-append, can take over a lock still
|
|
96
|
+
held; that costs at most the event being written.
|
|
97
|
+
|
|
84
98
|
Segments are removed only after **every configured source has committed past them**. A cursor that
|
|
85
99
|
advanced without its transaction committing would silently drop history, so cursors move only inside
|
|
86
100
|
the committing transaction.
|
|
@@ -104,7 +118,8 @@ and driven through the capture path in tests; a canary reaching any written byte
|
|
|
104
118
|
|
|
105
119
|
The provider's own error **code** is stored on purpose — it is what distinguishes a rate limit from
|
|
106
120
|
a timeout, and classifying that difference correctly is the entire reason SNACK does not treat your
|
|
107
|
-
flaky Wi-Fi as a quota event. The error _message_ is not stored.
|
|
121
|
+
flaky Wi-Fi as a quota event. The error _message_ is not stored. A retry status is read for its type
|
|
122
|
+
alone; its message is never kept or classified.
|
|
108
123
|
|
|
109
124
|
## Compatibility
|
|
110
125
|
|
|
@@ -113,5 +128,10 @@ Requires Node.js 24 and a `@snack-ai/cli` that accepts `spool-event-v1`. Event `
|
|
|
113
128
|
version of this plugin. `snack doctor` reports a registration pinned at an older version as outdated
|
|
114
129
|
rather than incompatible, and re-running `snack setup opencode --install-plugin` updates the pin.
|
|
115
130
|
|
|
131
|
+
On OpenCode `1.18.15`, use `1.0.5` or later. Earlier versions file the first prompt of every session
|
|
132
|
+
under the provider of OpenCode's `small_model`, which names the session, follow a cancelled prompt
|
|
133
|
+
with `success` events, and leave the model name empty. A current `snack sync` keeps the exclusion
|
|
134
|
+
OpenCode's database records for such a cancellation.
|
|
135
|
+
|
|
116
136
|
Apache-2.0. Security reports go through the private channel in
|
|
117
137
|
[SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
|
package/README.pt-BR.md
CHANGED
|
@@ -12,7 +12,9 @@ O OpenCode já anota o que você fez. Este plugin assiste isso acontecer, em vez
|
|
|
12
12
|
Essa diferença importa mais do que parece. Algumas coisas simplesmente não sobrevivem até o disco —
|
|
13
13
|
um prompt que o provedor recusa de cara pode não deixar rastro durável no banco do próprio OpenCode,
|
|
14
14
|
e uma recusa que o SNACK não enxerga é uma recusa com a qual ele não aprende. O plugin pega essas no
|
|
15
|
-
ato.
|
|
15
|
+
ato — quando o OpenCode reporta uma com código de status. O OpenCode `1.18.15` repete sozinho um 429
|
|
16
|
+
e reporta a nova tentativa só como texto livre do provedor, então nesse turno o plugin não declara
|
|
17
|
+
desfecho algum e deixa isso para a leitura que o SNACK faz do banco.
|
|
16
18
|
|
|
17
19
|
Ele é propositalmente minúsculo. Acrescenta uma linha de JSON por evento num arquivo privado e sai
|
|
18
20
|
da frente. Nunca abre banco, nunca importa a CLI do SNACK, nunca liga para lugar nenhum, e nunca —
|
|
@@ -23,7 +25,7 @@ sob falha alguma — entra entre você e o seu prompt.
|
|
|
23
25
|
A CLI do SNACK registra o plugin na configuração do próprio OpenCode para você:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
npm install -g @snack-ai/cli
|
|
28
|
+
npm install -g --allow-scripts=better-sqlite3 @snack-ai/cli # compila o driver SQLite; o npm 12 pula sem isso
|
|
27
29
|
snack setup opencode --install-plugin
|
|
28
30
|
```
|
|
29
31
|
|
|
@@ -42,12 +44,12 @@ byte-idêntica. Todo campo é metadado:
|
|
|
42
44
|
|
|
43
45
|
- a qual prompt e sessão pertence, por identificador;
|
|
44
46
|
- o provedor e o modelo, e quando aconteceu;
|
|
45
|
-
- como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua
|
|
46
|
-
|
|
47
|
-
- contagens de tokens e custo, como o provedor reportou.
|
|
47
|
+
- como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua classe
|
|
48
|
+
— uma vez por prompt, e nenhuma para um turno que o OpenCode repetiu.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
Contagens de tokens e custo não estão entre eles: o schema tem lugar para uso, mas o plugin o deixa
|
|
51
|
+
vazio e o SNACK tira o uso do banco do OpenCode. Não existe campo para texto de prompt nem de
|
|
52
|
+
resposta, e o schema recusa campos desconhecidos de forma categórica.
|
|
51
53
|
|
|
52
54
|
Com `--enable-prospective-analysis`, cada prompt carrega também algumas features não semânticas de
|
|
53
55
|
formato: contagem estimada de tokens, contagem de linhas em faixas, contagem de blocos de código em
|
|
@@ -77,13 +79,24 @@ não reconhece carregaria um significado inventado rio abaixo por todo o tempo e
|
|
|
77
79
|
|
|
78
80
|
Eventos são acrescentados como NDJSON em arquivos de segmento com permissão `0600` num diretório
|
|
79
81
|
`0700`. Acrescentar é a única operação de escrita; nada é reescrito no lugar, e é isso que torna uma
|
|
80
|
-
queda no meio da escrita recuperável em vez de corruptora.
|
|
82
|
+
queda no meio da escrita recuperável em vez de corruptora. Uma escrita que falha é cortada de volta
|
|
83
|
+
até onde começou, e um acréscimo a um segmento que termina no meio de uma linha começa numa linha
|
|
84
|
+
nova, então uma linha quebrada nunca leva o evento seguinte junto.
|
|
81
85
|
|
|
82
86
|
Uma linha cortada por uma queda é exatamente o que a recuperação de truncamento espera: o leitor
|
|
83
87
|
valida cada linha, descarta a incompleta com um diagnóstico sanitizado, e mantém tudo que veio
|
|
84
88
|
antes. A contagem de registros recusados aparece no `snack sync` como `rejected_invalid` em vez de
|
|
85
89
|
sumir.
|
|
86
90
|
|
|
91
|
+
Cada acréscimo segura um lock de escrita pelos milissegundos que dura. Um lock com mais de dois
|
|
92
|
+
minutos foi abandonado, então o plugin e o `snack sync` o assumem seja qual for o id de processo que
|
|
93
|
+
ele nomeia, e o `snack doctor` avisa com `spool_lock:<alias>` enquanto houver um. A tomada é
|
|
94
|
+
atômica: dois escritores que julgam o mesmo lock abandonado ao mesmo tempo nunca o seguram juntos, e
|
|
95
|
+
um acréscimo que falhou só é truncado de volta por um escritor que ainda segura o lock. A idade é
|
|
96
|
+
lida no relógio de parede, então um salto de relógio de mais de dois minutos, ou um notebook que
|
|
97
|
+
volta da suspensão no meio de um acréscimo, pode tomar um lock ainda em uso; isso custa no máximo o
|
|
98
|
+
evento sendo escrito.
|
|
99
|
+
|
|
87
100
|
Segmentos só são removidos depois que **toda fonte configurada commitou além deles**. Um cursor que
|
|
88
101
|
avançasse sem sua transação commitar descartaria histórico em silêncio, então cursores só se movem
|
|
89
102
|
dentro da transação que commita.
|
|
@@ -108,7 +121,8 @@ qualquer byte escrito quebra o build.
|
|
|
108
121
|
|
|
109
122
|
O **código** de erro do provedor é armazenado de propósito — é o que distingue um rate limit de um
|
|
110
123
|
timeout, e classificar essa diferença corretamente é a razão inteira de o SNACK não tratar o seu
|
|
111
|
-
Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada.
|
|
124
|
+
Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada. Um status de nova
|
|
125
|
+
tentativa é lido só pelo seu tipo; a mensagem dele nunca é guardada nem classificada.
|
|
112
126
|
|
|
113
127
|
## Compatibilidade
|
|
114
128
|
|
|
@@ -117,5 +131,10 @@ Requer Node.js 24 e uma `@snack-ai/cli` que aceite `spool-event-v1`. O `schema_v
|
|
|
117
131
|
publicada dele. O `snack doctor` reporta um registro fixado numa versão antiga como desatualizado, e
|
|
118
132
|
não como incompatível; rodar `snack setup opencode --install-plugin` de novo atualiza o pin.
|
|
119
133
|
|
|
134
|
+
No OpenCode `1.18.15`, use a `1.0.5` ou posterior. As versões anteriores registram o primeiro prompt
|
|
135
|
+
de toda sessão sob o provedor do `small_model` do OpenCode, que dá nome à sessão, gravam eventos de
|
|
136
|
+
`success` depois de um prompt cancelado, e deixam o nome do modelo vazio. Um `snack sync` atual
|
|
137
|
+
mantém a exclusão que o banco do OpenCode registra para esse cancelamento.
|
|
138
|
+
|
|
120
139
|
Apache-2.0. Relatos de segurança vão pelo canal privado descrito em
|
|
121
140
|
[SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
|
package/package.json
CHANGED
package/src/plugin.js
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
1
|
import { join } from "node:path";
|
|
4
|
-
import process from "node:process";
|
|
5
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
6
2
|
|
|
7
|
-
|
|
3
|
+
import { appendEvent } from "./spool-writer.js";
|
|
4
|
+
|
|
8
5
|
const maxPendingWrites = 100;
|
|
9
|
-
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Agents OpenCode runs on the side of a prompt, on models of their own: `title` names the session
|
|
9
|
+
* with `small_model` on its first prompt, `compaction` and `summary` rewrite history. Their
|
|
10
|
+
* `chat.params` describe a call the prompt did not ask for, so they never route one.
|
|
11
|
+
*/
|
|
12
|
+
const auxiliaryAgents = new Set(["title", "compaction", "summary"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} PromptState
|
|
16
|
+
* @property {string} promptId
|
|
17
|
+
* @property {string | null} agent
|
|
18
|
+
* @property {string | null} provider
|
|
19
|
+
* @property {string | null} model
|
|
20
|
+
* @property {string} spoolDirectory
|
|
21
|
+
* @property {boolean} retried
|
|
22
|
+
* @property {Record<string, unknown>[]} buffered
|
|
23
|
+
*/
|
|
10
24
|
|
|
11
25
|
/**
|
|
12
26
|
* Fail-open OpenCode plugin entrypoint.
|
|
13
27
|
*
|
|
14
28
|
* @param {unknown} _context
|
|
15
|
-
* @param {{installation_id?: unknown, spool_directory?: unknown, prospective_analysis?: unknown, source_bindings?: unknown}} [options]
|
|
29
|
+
* @param {{installation_id?: unknown, spool_directory?: unknown, prospective_analysis?: unknown, source_bindings?: unknown} | null} [options]
|
|
16
30
|
*/
|
|
17
31
|
export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
32
|
+
// OpenCode passes whatever the configuration tuple holds, `null` and non-objects included; an
|
|
33
|
+
// initializer that throws there takes the host down with it.
|
|
34
|
+
const settings = recordOrNull(options) ?? {};
|
|
35
|
+
const installationId = stringOrNull(settings.installation_id);
|
|
36
|
+
const spoolDirectory = stringOrNull(settings.spool_directory);
|
|
37
|
+
const captureFeatures = settings.prospective_analysis === true;
|
|
38
|
+
const sourceBindings = bindingMap(settings.source_bindings);
|
|
39
|
+
/** @type {Map<string, PromptState>} */
|
|
23
40
|
const prompts = new Map();
|
|
24
41
|
let writes = Promise.resolve();
|
|
25
42
|
let pendingWrites = 0;
|
|
@@ -59,13 +76,14 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
59
76
|
/**
|
|
60
77
|
* Route a session's spool directory once its provider is known.
|
|
61
78
|
*
|
|
62
|
-
* OpenCode declares `model` optional on `chat.message` and does not send it on `1.18.10
|
|
63
|
-
*
|
|
64
|
-
* never revisited, so a prompt whose provider
|
|
65
|
-
*
|
|
66
|
-
*
|
|
79
|
+
* OpenCode declares `model` optional on `chat.message` and does not send it on `1.18.10` or
|
|
80
|
+
* `1.18.15`; the user message on the hook's output names it there, and older hosts may not. An
|
|
81
|
+
* event written to `_pending` is never attributed and never revisited, so a prompt whose provider
|
|
82
|
+
* is still unknown is held rather than misfiled, and released as soon as the prompt's own
|
|
83
|
+
* `chat.params` names one. A prompt whose provider never arrives is released to `_pending` at
|
|
84
|
+
* its terminal event, or when the next prompt of its session replaces it.
|
|
67
85
|
*
|
|
68
|
-
* @param {
|
|
86
|
+
* @param {PromptState} prompt
|
|
69
87
|
*/
|
|
70
88
|
const release = (prompt) => {
|
|
71
89
|
for (const event of prompt.buffered.splice(0)) {
|
|
@@ -75,19 +93,33 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
75
93
|
|
|
76
94
|
return {
|
|
77
95
|
async dispose() {
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
try {
|
|
97
|
+
for (const prompt of prompts.values()) release(prompt);
|
|
98
|
+
prompts.clear();
|
|
99
|
+
await writes;
|
|
100
|
+
} catch {
|
|
101
|
+
// Capture must never change OpenCode shutdown behavior.
|
|
102
|
+
}
|
|
80
103
|
},
|
|
81
104
|
async "chat.params"(/** @type {Record<string, unknown>} */ input) {
|
|
82
105
|
try {
|
|
83
106
|
const sessionId = stringOrNull(input.sessionID);
|
|
84
107
|
const prompt = sessionId ? prompts.get(sessionId) : undefined;
|
|
85
108
|
if (!prompt || prompt.provider || !spoolDirectory) return;
|
|
109
|
+
// Only the call that answers this prompt routes it. On 1.18.15 the session title is
|
|
110
|
+
// generated with `small_model` -- possibly another provider -- and its `chat.params`
|
|
111
|
+
// arrives first, on the first prompt of every session.
|
|
112
|
+
const agent = stringOrNull(input.agent);
|
|
113
|
+
if (agent !== null && auxiliaryAgents.has(agent)) return;
|
|
114
|
+
if (prompt.agent !== null && agent !== null && agent !== prompt.agent) return;
|
|
115
|
+
const answered = stringOrNull(recordOrNull(input.message)?.id);
|
|
116
|
+
if (answered !== null && answered !== prompt.promptId) return;
|
|
86
117
|
const model = recordOrNull(input.model);
|
|
87
118
|
const provider = stringOrNull(model?.providerID);
|
|
88
119
|
if (!provider) return;
|
|
89
120
|
prompt.provider = provider;
|
|
90
|
-
|
|
121
|
+
// `chat.params` carries the provider's model record, which names the model `id`.
|
|
122
|
+
prompt.model = stringOrNull(model?.id) ?? stringOrNull(model?.modelID);
|
|
91
123
|
prompt.spoolDirectory = sourceBindings.get(provider) ?? join(spoolDirectory, "_pending");
|
|
92
124
|
release(prompt);
|
|
93
125
|
} catch {
|
|
@@ -103,20 +135,27 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
103
135
|
const outputMessage = recordOrNull(recordOrNull(output)?.message);
|
|
104
136
|
const promptId = stringOrNull(input.messageID) ?? stringOrNull(outputMessage?.id);
|
|
105
137
|
if (!sessionId || !promptId || !spoolDirectory) return;
|
|
106
|
-
const model = recordOrNull(input.model);
|
|
138
|
+
const model = recordOrNull(input.model) ?? recordOrNull(outputMessage?.model);
|
|
107
139
|
const provider = stringOrNull(model?.providerID);
|
|
108
140
|
const modelId = stringOrNull(model?.modelID);
|
|
109
141
|
const targetDirectory = provider
|
|
110
142
|
? (sourceBindings.get(provider) ?? join(spoolDirectory, "_pending"))
|
|
111
143
|
: join(spoolDirectory, "_pending");
|
|
112
144
|
if (!targetDirectory) return;
|
|
145
|
+
/** @type {PromptState} */
|
|
113
146
|
const prompt = {
|
|
114
147
|
promptId,
|
|
148
|
+
agent: stringOrNull(input.agent) ?? stringOrNull(outputMessage?.agent),
|
|
115
149
|
provider,
|
|
116
150
|
model: modelId,
|
|
117
151
|
spoolDirectory: targetDirectory,
|
|
118
|
-
|
|
152
|
+
retried: false,
|
|
153
|
+
buffered: [],
|
|
119
154
|
};
|
|
155
|
+
// A prompt queued behind one still held for its provider replaces it here; what the
|
|
156
|
+
// earlier one buffered is released, to `_pending`, rather than dropped.
|
|
157
|
+
const previous = prompts.get(sessionId);
|
|
158
|
+
if (previous) release(previous);
|
|
120
159
|
prompts.set(sessionId, prompt);
|
|
121
160
|
const occurredAt = new Date().toISOString();
|
|
122
161
|
const started = {
|
|
@@ -148,15 +187,32 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
148
187
|
try {
|
|
149
188
|
const event = recordOrNull(input.event);
|
|
150
189
|
const type = stringOrNull(event?.type);
|
|
151
|
-
if (type !== "session.idle" && type !== "session.error")
|
|
190
|
+
if (type !== "session.idle" && type !== "session.error" && type !== "session.status")
|
|
191
|
+
return;
|
|
152
192
|
const properties = recordOrNull(event?.properties) ?? {};
|
|
153
193
|
const sessionId = stringOrNull(properties.sessionID);
|
|
154
194
|
const prompt = sessionId ? prompts.get(sessionId) : undefined;
|
|
155
195
|
if (!sessionId || !prompt) return;
|
|
196
|
+
if (type === "session.status") {
|
|
197
|
+
// Only the status's type is read. Its `message` is the provider's free text and is
|
|
198
|
+
// never kept, compared, or classified.
|
|
199
|
+
if (stringOrNull(recordOrNull(properties.status)?.type) === "retry")
|
|
200
|
+
prompt.retried = true;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// One terminal per prompt. 1.18.15 emits `session.idle` after `session.error`, twice after
|
|
204
|
+
// an abort, and again after every later `/shell` or `/summarize`: a prompt still in the map
|
|
205
|
+
// would be re-emitted, each time later than the last.
|
|
206
|
+
prompts.delete(sessionId);
|
|
207
|
+
release(prompt);
|
|
208
|
+
// OpenCode retries a 429 itself and reports it only as `session.status` `retry`, which
|
|
209
|
+
// carries no structured status code; the turn then ends in `session.idle` whether it
|
|
210
|
+
// succeeded or was cancelled. `spool-event-v1` lets `session_idle` say only `success`, so a
|
|
211
|
+
// retried turn states no terminal at all and backfill, which reads how it ended, decides.
|
|
212
|
+
if (type === "session.idle" && prompt.retried) return;
|
|
156
213
|
const occurredAt = timestampOrNow(properties.time);
|
|
157
214
|
const error = recordOrNull(properties.error);
|
|
158
215
|
const restricted = type === "session.error" && isExplicitRateLimit(error);
|
|
159
|
-
release(prompt);
|
|
160
216
|
append(prompt.spoolDirectory, {
|
|
161
217
|
schema_version: 1,
|
|
162
218
|
event_id: `${type}:${sessionId}:${prompt.promptId}:${occurredAt}`,
|
|
@@ -191,103 +247,6 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
191
247
|
};
|
|
192
248
|
}
|
|
193
249
|
|
|
194
|
-
/** @param {string} spoolDirectory @param {Record<string, unknown>} event */
|
|
195
|
-
async function appendEvent(spoolDirectory, event) {
|
|
196
|
-
await mkdir(spoolDirectory, { recursive: true, mode: 0o700 });
|
|
197
|
-
await chmod(spoolDirectory, 0o700);
|
|
198
|
-
const release = await acquireSpoolLock(spoolDirectory);
|
|
199
|
-
try {
|
|
200
|
-
const file = join(spoolDirectory, spoolFilename);
|
|
201
|
-
try {
|
|
202
|
-
if ((await stat(file)).size >= maxSegmentBytes) {
|
|
203
|
-
await rename(file, join(spoolDirectory, `segment-${Date.now()}-${randomUUID()}.ndjson`));
|
|
204
|
-
}
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
207
|
-
}
|
|
208
|
-
const handle = await open(file, "a", 0o600);
|
|
209
|
-
try {
|
|
210
|
-
await handle.writeFile(`${JSON.stringify(event)}\n`, "utf8");
|
|
211
|
-
await handle.sync();
|
|
212
|
-
} finally {
|
|
213
|
-
await handle.close();
|
|
214
|
-
}
|
|
215
|
-
await chmod(file, 0o600);
|
|
216
|
-
} finally {
|
|
217
|
-
await release();
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** @param {string} spoolDirectory */
|
|
222
|
-
async function acquireSpoolLock(spoolDirectory) {
|
|
223
|
-
const lock = join(spoolDirectory, ".writer.lock");
|
|
224
|
-
const token = randomUUID();
|
|
225
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
226
|
-
try {
|
|
227
|
-
const handle = await open(lock, "wx", 0o600);
|
|
228
|
-
await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
|
|
229
|
-
await handle.sync();
|
|
230
|
-
if ((await readSpoolLock(lock))?.token !== token) {
|
|
231
|
-
await handle.close();
|
|
232
|
-
if (attempt < 3) continue;
|
|
233
|
-
throw new Error("Spool writer lost lock ownership.");
|
|
234
|
-
}
|
|
235
|
-
return async () => {
|
|
236
|
-
await handle.close();
|
|
237
|
-
if ((await readSpoolLock(lock))?.token === token) await rm(lock, { force: true });
|
|
238
|
-
};
|
|
239
|
-
} catch (error) {
|
|
240
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
241
|
-
const owner = await readSpoolLock(lock);
|
|
242
|
-
if (owner !== null && !processIsAlive(owner.pid)) {
|
|
243
|
-
await rm(lock, { force: true });
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (owner === null && (await lockIsStale(lock))) {
|
|
247
|
-
await rm(lock, { force: true });
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (attempt < 3) await delay(2);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
throw new Error("Spool writer is busy.");
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** @param {string} lock */
|
|
257
|
-
async function lockIsStale(lock) {
|
|
258
|
-
try {
|
|
259
|
-
return Date.now() - (await stat(lock)).mtimeMs > 120_000;
|
|
260
|
-
} catch {
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** @param {string} lock */
|
|
266
|
-
async function readSpoolLock(lock) {
|
|
267
|
-
try {
|
|
268
|
-
const value = JSON.parse(await readFile(lock, "utf8"));
|
|
269
|
-
return recordOrNull(value) &&
|
|
270
|
-
typeof value.pid === "number" &&
|
|
271
|
-
Number.isSafeInteger(value.pid) &&
|
|
272
|
-
value.pid > 0 &&
|
|
273
|
-
typeof value.token === "string"
|
|
274
|
-
? { pid: value.pid, token: value.token }
|
|
275
|
-
: null;
|
|
276
|
-
} catch {
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** @param {number} pid */
|
|
282
|
-
function processIsAlive(pid) {
|
|
283
|
-
try {
|
|
284
|
-
process.kill(pid, 0);
|
|
285
|
-
return true;
|
|
286
|
-
} catch (error) {
|
|
287
|
-
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
250
|
/** @param {unknown} output */
|
|
292
251
|
function analyzePrompt(output) {
|
|
293
252
|
const parts = recordOrNull(output)?.parts;
|
|
@@ -346,8 +305,13 @@ function isExplicitRateLimit(error) {
|
|
|
346
305
|
|
|
347
306
|
/** @param {unknown} value */
|
|
348
307
|
function timestampOrNow(value) {
|
|
349
|
-
if (typeof value === "string"
|
|
350
|
-
|
|
308
|
+
if (typeof value === "string") {
|
|
309
|
+
const parsed = new Date(value);
|
|
310
|
+
const year = parsed.getUTCFullYear();
|
|
311
|
+
// `toISOString` spells a year outside 0000-9999 with a sign and six digits, which the schema's
|
|
312
|
+
// `date-time` refuses -- the line would be read back as corruption.
|
|
313
|
+
if (!Number.isNaN(parsed.getTime()) && year >= 0 && year <= 9999) return parsed.toISOString();
|
|
314
|
+
}
|
|
351
315
|
return new Date().toISOString();
|
|
352
316
|
}
|
|
353
317
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import * as nodeFs from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
|
|
8
|
+
const spoolFilename = "current.open";
|
|
9
|
+
const maxSegmentBytes = 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* A lock is held for the milliseconds one append takes; one this old was abandoned. Judged by the
|
|
12
|
+
* wall clock against the lock's mtime, so a clock jump of more than this, or a laptop resumed
|
|
13
|
+
* mid-append, can take over a lock still held. That costs at most one event; the takeover below
|
|
14
|
+
* and the check before a truncate keep it from costing another writer's.
|
|
15
|
+
*/
|
|
16
|
+
const staleLockMs = 120_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The file operations the writer performs, injectable so a test can interleave two writers.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {Pick<typeof import("node:fs/promises"), "chmod" | "link" | "mkdir" | "open" | "readFile" | "rename" | "rm" | "stat">} SpoolFs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Append one event to a spool directory's open segment, under its writer lock.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} spoolDirectory
|
|
28
|
+
* @param {Record<string, unknown>} event
|
|
29
|
+
* @param {SpoolFs} [fs]
|
|
30
|
+
*/
|
|
31
|
+
export async function appendEvent(spoolDirectory, event, fs = nodeFs) {
|
|
32
|
+
await fs.mkdir(spoolDirectory, { recursive: true, mode: 0o700 });
|
|
33
|
+
await fs.chmod(spoolDirectory, 0o700);
|
|
34
|
+
const lock = await acquireSpoolLock(spoolDirectory, fs);
|
|
35
|
+
try {
|
|
36
|
+
const file = join(spoolDirectory, spoolFilename);
|
|
37
|
+
try {
|
|
38
|
+
if ((await fs.stat(file)).size >= maxSegmentBytes) {
|
|
39
|
+
await fs.rename(file, join(spoolDirectory, `segment-${Date.now()}-${randomUUID()}.ndjson`));
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
43
|
+
}
|
|
44
|
+
const handle = await fs.open(file, "a+", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
// A write cut short -- a full disk, a file-size limit, a killed host -- leaves a line with no
|
|
47
|
+
// newline, and the next event appended to it is glued on and lost with it. Starting on a
|
|
48
|
+
// fresh line confines the damage to the line that was already broken.
|
|
49
|
+
const { size } = await handle.stat();
|
|
50
|
+
const separator = size > 0 && !(await endsWithNewline(handle, size)) ? "\n" : "";
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(`${separator}${JSON.stringify(event)}\n`, "utf8");
|
|
53
|
+
await handle.sync();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Take back whatever part of this event landed, so the failure leaves no partial line --
|
|
56
|
+
// only while this writer still holds the lock. One whose lock was taken over may share the
|
|
57
|
+
// file with the writer that took it, and cutting back to `size` would erase that writer's
|
|
58
|
+
// line; a partial line is the lesser loss, and it rejects only itself.
|
|
59
|
+
if (await lock.holds()) await handle.truncate(size).catch(() => {});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
await fs.chmod(file, 0o600);
|
|
66
|
+
} finally {
|
|
67
|
+
await lock.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Take a spool directory's writer lock.
|
|
73
|
+
*
|
|
74
|
+
* A lock whose writer is gone, or which is older than any append, is taken over. Two writers can
|
|
75
|
+
* judge the same lock abandoned at once, and removing it by path let the slower one delete the lock
|
|
76
|
+
* the faster one had just created in its place, so both held it. A takeover therefore moves the
|
|
77
|
+
* lock aside to a name of its own -- `rename` moves exactly one file -- and only proceeds when what
|
|
78
|
+
* it moved is the lock it judged. Having moved a lock another writer took in the meantime, it puts
|
|
79
|
+
* that lock back with `link`, which never replaces a lock created since.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} spoolDirectory
|
|
82
|
+
* @param {SpoolFs} [fs]
|
|
83
|
+
* @returns {Promise<{holds: () => Promise<boolean>, release: () => Promise<void>}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function acquireSpoolLock(spoolDirectory, fs = nodeFs) {
|
|
86
|
+
const lock = join(spoolDirectory, ".writer.lock");
|
|
87
|
+
const token = randomUUID();
|
|
88
|
+
const holds = async () => (await inspectLock(fs, lock))?.owner?.token === token;
|
|
89
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
90
|
+
/** @type {import("node:fs/promises").FileHandle} */
|
|
91
|
+
let handle;
|
|
92
|
+
try {
|
|
93
|
+
handle = await fs.open(lock, "wx", 0o600);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
96
|
+
const held = await inspectLock(fs, lock);
|
|
97
|
+
// Our own token in a lock we gave up on is a lock a takeover moved and put back while we
|
|
98
|
+
// were checking it: it is abandoned by definition.
|
|
99
|
+
if (held !== null && (held.owner?.token === token || isAbandoned(held))) {
|
|
100
|
+
await takeOver(fs, lock, held);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (attempt < 3) await delay(2);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
|
|
107
|
+
await handle.sync();
|
|
108
|
+
if (!(await holds())) {
|
|
109
|
+
await handle.close();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
holds,
|
|
114
|
+
async release() {
|
|
115
|
+
await handle.close();
|
|
116
|
+
const held = await inspectLock(fs, lock);
|
|
117
|
+
if (held?.owner?.token === token) await takeOver(fs, lock, held);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
throw new Error("Spool writer is busy.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove the lock `judged` described, and nothing else.
|
|
126
|
+
*
|
|
127
|
+
* @param {SpoolFs} fs
|
|
128
|
+
* @param {string} lock
|
|
129
|
+
* @param {LockState} judged
|
|
130
|
+
*/
|
|
131
|
+
async function takeOver(fs, lock, judged) {
|
|
132
|
+
const tombstone = `${lock}.${randomUUID()}.stale`;
|
|
133
|
+
try {
|
|
134
|
+
await fs.rename(lock, tombstone);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Another writer moved it first; whatever is there now is judged afresh.
|
|
137
|
+
if (hasCode(error, "ENOENT")) return;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const moved = await inspectLock(fs, tombstone);
|
|
141
|
+
if (
|
|
142
|
+
moved !== null &&
|
|
143
|
+
moved.ino === judged.ino &&
|
|
144
|
+
moved.mtimeMs === judged.mtimeMs &&
|
|
145
|
+
moved.owner?.token === judged.owner?.token
|
|
146
|
+
) {
|
|
147
|
+
await fs.rm(tombstone, { force: true });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Not the lock that was judged: a writer that took over first holds it. Put it back, unless a
|
|
151
|
+
// lock was created in its place meanwhile, which then stands.
|
|
152
|
+
try {
|
|
153
|
+
await fs.link(tombstone, lock);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!hasCode(error, "EEXIST")) await fs.rename(tombstone, lock).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
await fs.rm(tombstone, { force: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {{ino: number, mtimeMs: number, owner: {pid: number, token: string} | null}} LockState
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {SpoolFs} fs
|
|
166
|
+
* @param {string} lock
|
|
167
|
+
* @returns {Promise<LockState | null>}
|
|
168
|
+
*/
|
|
169
|
+
async function inspectLock(fs, lock) {
|
|
170
|
+
let stats;
|
|
171
|
+
try {
|
|
172
|
+
stats = await fs.stat(lock);
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return { ino: stats.ino, mtimeMs: stats.mtimeMs, owner: await readOwner(fs, lock) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Age decides before the pid does: a pid that answers `kill(pid, 0)` may have been reused, or
|
|
181
|
+
* belong to another user, and a lock held that long is not being used by anyone.
|
|
182
|
+
*
|
|
183
|
+
* @param {LockState} held
|
|
184
|
+
*/
|
|
185
|
+
function isAbandoned(held) {
|
|
186
|
+
return (
|
|
187
|
+
(held.owner !== null && !processIsAlive(held.owner.pid)) ||
|
|
188
|
+
Date.now() - held.mtimeMs > staleLockMs
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {SpoolFs} fs @param {string} lock */
|
|
193
|
+
async function readOwner(fs, lock) {
|
|
194
|
+
try {
|
|
195
|
+
const value = JSON.parse(await fs.readFile(lock, "utf8"));
|
|
196
|
+
return typeof value === "object" &&
|
|
197
|
+
value !== null &&
|
|
198
|
+
typeof value.pid === "number" &&
|
|
199
|
+
Number.isSafeInteger(value.pid) &&
|
|
200
|
+
value.pid > 0 &&
|
|
201
|
+
typeof value.token === "string"
|
|
202
|
+
? { pid: value.pid, token: value.token }
|
|
203
|
+
: null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param {import("node:fs/promises").FileHandle} handle @param {number} size */
|
|
210
|
+
async function endsWithNewline(handle, size) {
|
|
211
|
+
const last = Buffer.alloc(1);
|
|
212
|
+
await handle.read(last, 0, 1, size - 1);
|
|
213
|
+
return last[0] === 0x0a;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {number} pid */
|
|
217
|
+
function processIsAlive(pid) {
|
|
218
|
+
try {
|
|
219
|
+
process.kill(pid, 0);
|
|
220
|
+
return true;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return hasCode(error, "EPERM");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {unknown} error @param {string} code */
|
|
227
|
+
function hasCode(error, code) {
|
|
228
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
229
|
+
}
|