@tduyng/pi-autoresearch 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 +47 -56
- package/package.json +2 -2
- package/src/experiment.ts +36 -41
- package/src/git.ts +13 -20
- package/src/index.ts +4 -2
- package/src/session.ts +25 -11
package/README.md
CHANGED
|
@@ -1,45 +1,37 @@
|
|
|
1
1
|
# @tduyng/pi-autoresearch
|
|
2
2
|
|
|
3
|
-
An
|
|
4
|
-
Two tools and one log. The model reads the benchmark, decides keep or discard, and the tools do the
|
|
5
|
-
bookkeeping: commit, revert, scope, script hash, noise floor, and one line of context back.
|
|
3
|
+
An experiment loop you turn on with one command. Measure, read the number, keep or discard, and let the tools handle git, scope, and the noise floor.
|
|
6
4
|
|
|
7
|
-
##
|
|
5
|
+
## What it costs
|
|
8
6
|
|
|
9
|
-
Nothing
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
Nothing at cold start. No tool, no skill, no system prompt block: the extension adds one slash command, and that is free until typed. The two tools are declared to the model only while `/autoresearch` runs, and removed on `off`, `clear`, or when `max_runs` is reached.
|
|
8
|
+
|
|
9
|
+
The saving is the second half of every optimization loop. Editing a benchmark, committing a regression, and re-reading a whole log by hand are the parts that cost turns, and all three are one line of bookkeeping here.
|
|
12
10
|
|
|
13
11
|
## The loop
|
|
14
12
|
|
|
15
|
-
```
|
|
13
|
+
```text
|
|
16
14
|
edit -> run_experiment -> read the output -> log_experiment -> repeat
|
|
17
15
|
```
|
|
18
16
|
|
|
19
|
-
`run_experiment` snapshots the working tree, runs `.auto/measure.sh`, parses `METRIC name=value` lines, runs
|
|
20
|
-
the optional checks, and returns the output, the metrics, the changed files, the noise advice, and the
|
|
21
|
-
scope violations. It does not touch git history.
|
|
17
|
+
`run_experiment` snapshots the working tree, runs `.auto/measure.sh`, parses `METRIC name=value` lines, runs the optional checks, and returns the output, the metrics, the changed files, the noise advice, and the scope violations. It does not touch git history.
|
|
22
18
|
|
|
23
|
-
`log_experiment` takes `status: keep | discard | crash | checks_failed` and the model's reasoning in `asi`.
|
|
24
|
-
A `keep` commits the files the run touched with a conventional subject built from the description. Every
|
|
25
|
-
other status restores those files. A run inside the noise band, a crash, or a scope violation is reported,
|
|
26
|
-
not auto-decided: the model chooses.
|
|
19
|
+
`log_experiment` takes `status: keep | discard | crash | checks_failed` and the model's reasoning in `asi`. A `keep` commits the files the run touched with a conventional subject built from the description. Every other status restores those files. A run inside the noise band, a crash, or a scope violation is reported rather than decided, because a rule cannot know whether the diff caused the gain.
|
|
27
20
|
|
|
28
21
|
## What it adds over the usual loop
|
|
29
22
|
|
|
30
23
|
| Mechanism | Why |
|
|
31
24
|
| --- | --- |
|
|
32
|
-
| Model owns keep or discard |
|
|
33
|
-
| Full benchmark output |
|
|
34
|
-
| Noise floor from median absolute deviation |
|
|
35
|
-
| Pending measurement |
|
|
36
|
-
| Scope globs in frontmatter |
|
|
37
|
-
| Benchmark script hash |
|
|
38
|
-
| Worktree lock and dirty baseline |
|
|
39
|
-
| Finalize in temporary worktrees |
|
|
40
|
-
| Compaction digest from disk |
|
|
41
|
-
| Conventional commits | `type(scope): summary`, at most 72 characters, one `Metric:` trailer, built
|
|
42
|
-
| Lazy tool registration | zero tokens at cold start |
|
|
25
|
+
| Model owns keep or discard | A rule cannot tell whether 1% came from the diff, or whether simpler code is worth 1% |
|
|
26
|
+
| Full benchmark output | Phase timings and errors reach the model, so the next hypothesis comes from data |
|
|
27
|
+
| Noise floor from median absolute deviation | Advice that separates jitter from a real win, and confidence never overrides the model |
|
|
28
|
+
| Pending measurement | One run between the tools, crash-safe on disk, so a restart can still log it |
|
|
29
|
+
| Scope globs in frontmatter | A keep cannot touch files outside the declared scope |
|
|
30
|
+
| Benchmark script hash | Editing the benchmark to move the number voids the comparison |
|
|
31
|
+
| Worktree lock and dirty baseline | One loop per worktree, and files another session left dirty are never committed |
|
|
32
|
+
| Finalize in temporary worktrees | Kept runs become branches without moving HEAD under another session |
|
|
33
|
+
| Compaction digest from disk | Prompt, recent runs, dead ends, and ideas survive a reset with no model call |
|
|
34
|
+
| Conventional commits | `type(scope): summary`, at most 72 characters, one `Metric:` trailer, built from validated parts |
|
|
43
35
|
|
|
44
36
|
## Files
|
|
45
37
|
|
|
@@ -64,48 +56,47 @@ What is optimized, the constraints, and what a good change looks like.
|
|
|
64
56
|
|
|
65
57
|
| File | Purpose |
|
|
66
58
|
| --- | --- |
|
|
67
|
-
| `.auto/prompt.md` |
|
|
68
|
-
| `.auto/measure.sh` | benchmark,
|
|
69
|
-
| `.auto/log.jsonl` |
|
|
70
|
-
| `.auto/checks.sh` |
|
|
71
|
-
| `.auto/ideas.md` |
|
|
72
|
-
| `.auto/pending.json` |
|
|
73
|
-
| `.auto/lock.json` |
|
|
59
|
+
| `.auto/prompt.md` | Frontmatter config, the objective, and what has been tried |
|
|
60
|
+
| `.auto/measure.sh` | The benchmark, printing `METRIC name=value` |
|
|
61
|
+
| `.auto/log.jsonl` | Append-only record |
|
|
62
|
+
| `.auto/checks.sh` | Optional correctness checks, run after a passing benchmark |
|
|
63
|
+
| `.auto/ideas.md` | Optional backlog of ideas not tried |
|
|
64
|
+
| `.auto/pending.json` | The run waiting for a decision |
|
|
65
|
+
| `.auto/lock.json` | The session that owns this worktree |
|
|
74
66
|
|
|
75
|
-
`.auto/` is added to the repository's
|
|
76
|
-
commit, including from a linked worktree.
|
|
67
|
+
`.auto/` is added to the repository's `.git/info/exclude` on the first run, including from a linked worktree, so none of it lands in a commit.
|
|
77
68
|
|
|
78
|
-
Run it in an interactive
|
|
79
|
-
before the loop's first turn, so a print run cannot drive the loop.
|
|
69
|
+
Run it in an interactive session or through RPC. `pi -p` exits as soon as the slash command returns, before the loop's first turn, so a print run cannot drive it.
|
|
80
70
|
|
|
81
71
|
## Commands
|
|
82
72
|
|
|
83
73
|
| Command | Effect |
|
|
84
74
|
| --- | --- |
|
|
85
|
-
| `/autoresearch <goal>` |
|
|
86
|
-
| `/autoresearch status` |
|
|
87
|
-
| `/autoresearch off` |
|
|
88
|
-
| `/autoresearch rebaseline` |
|
|
89
|
-
| `/autoresearch finalize` |
|
|
90
|
-
| `/autoresearch clear` |
|
|
75
|
+
| `/autoresearch <goal>` | Start a session, or resume the one in `.auto/` |
|
|
76
|
+
| `/autoresearch status` | Print the digest |
|
|
77
|
+
| `/autoresearch off` | Stop and remove the tools |
|
|
78
|
+
| `/autoresearch rebaseline` | Accept a changed benchmark script as a new baseline |
|
|
79
|
+
| `/autoresearch finalize` | Split kept runs into independent branches from the merge-base |
|
|
80
|
+
| `/autoresearch clear` | Delete the log and stop |
|
|
91
81
|
|
|
92
|
-
`finalize` groups kept runs by file overlap and creates one branch per group from the merge-base in
|
|
93
|
-
temporary worktrees. The current worktree is never checked out to another branch. It verifies that the
|
|
94
|
-
union of the branches matches the original branch before reporting.
|
|
82
|
+
`finalize` groups kept runs by file overlap and creates one branch per group from the merge-base in temporary worktrees. The current worktree is never checked out to another branch, and the union of the branches is verified against the original before it reports.
|
|
95
83
|
|
|
96
84
|
## Worktrees and other sessions
|
|
97
85
|
|
|
98
|
-
One loop owns a worktree at a time
|
|
99
|
-
worktree is refused before it can run anything. Files that were already dirty when the loop started are
|
|
100
|
-
recorded in the lock and never committed or reverted.
|
|
86
|
+
One loop owns a worktree at a time: `/autoresearch` takes `.auto/lock.json` and refuses a second session before it can run anything. Files that were already dirty when the loop started are recorded in the lock and never committed or reverted.
|
|
101
87
|
|
|
102
|
-
For a second loop
|
|
103
|
-
its own branch, and its own log. The first run creates `autoresearch/<name>-<timestamp>` when you are on
|
|
104
|
-
trunk and leaves an existing feature branch alone.
|
|
88
|
+
For a second loop, use a separate git worktree, which gets its own `.auto/`, branch, and log. The first run on trunk creates `autoresearch/<name>-<timestamp>` in a worktree at `~/worktrees/<repo>/autoresearch-<name>-<timestamp>` and leaves the shared checkout on trunk. An existing feature branch is used as it is.
|
|
105
89
|
|
|
106
90
|
## Compaction and restarts
|
|
107
91
|
|
|
108
|
-
While the loop is active, `session_before_compact` replaces pi's
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
92
|
+
While the loop is active, `session_before_compact` replaces pi's model summary with a deterministic digest built from `.auto/`: the prompt body, recent runs, kept runs, dead ends, ideas, and any pending measurement. No model call, no lost history.
|
|
93
|
+
|
|
94
|
+
After a restart of the same session the loop re-arms from the session's activation entry and sends one short resume message. A fresh session never auto-activates.
|
|
95
|
+
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pi install npm:@tduyng/pi-autoresearch
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-autoresearch`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-autoresearch",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Autonomous experiment loop
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Autonomous experiment loop that costs nothing until you type /autoresearch: measure, decide, commit or revert, with scope and benchmark guards.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
package/src/experiment.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
2
3
|
|
|
3
|
-
import type {
|
|
4
|
-
AgentToolResult,
|
|
5
|
-
AgentToolUpdateCallback,
|
|
6
|
-
ExtensionContext
|
|
7
|
-
} from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
8
5
|
import { t } from 'ata-validator/t'
|
|
9
6
|
|
|
10
7
|
import { runCommand } from './command.js'
|
|
@@ -61,13 +58,10 @@ const CHECKS_TAIL_LINES = 8
|
|
|
61
58
|
const PENDING_TTL_MS = 24 * 60 * 60 * 1000
|
|
62
59
|
const FILE_LIST_LIMIT = 10
|
|
63
60
|
|
|
64
|
-
const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()])
|
|
65
|
-
)
|
|
61
|
+
const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()]))
|
|
66
62
|
|
|
67
63
|
const RunParams = t.object({
|
|
68
|
-
command: t.optional(
|
|
69
|
-
t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })
|
|
70
|
-
),
|
|
64
|
+
command: t.optional(t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })),
|
|
71
65
|
timeout_seconds: t.optional(t.number({ description: 'Kill the benchmark after this many seconds' }))
|
|
72
66
|
})
|
|
73
67
|
|
|
@@ -80,9 +74,7 @@ const LogParams = t.object({
|
|
|
80
74
|
description: t.string({
|
|
81
75
|
description: 'One imperative line naming the change, in the words of a commit subject ("use forks pool").'
|
|
82
76
|
}),
|
|
83
|
-
asi: t.optional(
|
|
84
|
-
AsiParam
|
|
85
|
-
),
|
|
77
|
+
asi: t.optional(AsiParam),
|
|
86
78
|
note: t.optional(t.string({ description: 'One line of what this run taught you. Shorthand for asi.note.' }))
|
|
87
79
|
})
|
|
88
80
|
|
|
@@ -187,11 +179,7 @@ export function outOfScope(files: readonly string[], scope: readonly string[]):
|
|
|
187
179
|
return files.filter((file) => !patterns.some((pattern) => pattern.test(file)))
|
|
188
180
|
}
|
|
189
181
|
|
|
190
|
-
function primaryMetric(
|
|
191
|
-
samples: readonly PendingSample[],
|
|
192
|
-
config: ResearchConfig,
|
|
193
|
-
seconds: number
|
|
194
|
-
): number | null {
|
|
182
|
+
function primaryMetric(samples: readonly PendingSample[], config: ResearchConfig, seconds: number): number | null {
|
|
195
183
|
const parsed = metricValue(samples, config.metric)
|
|
196
184
|
if (parsed !== null) return parsed
|
|
197
185
|
if (config.metric === 'duration' || config.metric === 'seconds') return seconds
|
|
@@ -274,42 +262,35 @@ async function executeRun(
|
|
|
274
262
|
output: result.output,
|
|
275
263
|
tail: result.tail,
|
|
276
264
|
fullOutputPath: result.fullOutputPath,
|
|
277
|
-
metrics: parseMetricLines(
|
|
265
|
+
metrics: parseMetricLines(
|
|
266
|
+
result.fullOutputPath === null ? result.output : readFileSync(result.fullOutputPath, 'utf8')
|
|
267
|
+
)
|
|
278
268
|
}
|
|
279
269
|
}
|
|
280
270
|
|
|
281
|
-
async function runChecks(
|
|
282
|
-
pi: GitApi,
|
|
283
|
-
cwd: string,
|
|
284
|
-
signal: AbortSignal | undefined
|
|
285
|
-
): Promise<ChecksOutcome | null> {
|
|
271
|
+
async function runChecks(pi: GitApi, cwd: string, signal: AbortSignal | undefined): Promise<ChecksOutcome | null> {
|
|
286
272
|
const path = checksPath(cwd)
|
|
287
273
|
if (!fileExists(path)) return null
|
|
288
|
-
const result = await pi
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
.
|
|
295
|
-
|
|
296
|
-
.slice(-CHECKS_TAIL_LINES)
|
|
297
|
-
.join('\n')
|
|
274
|
+
const result = await pi
|
|
275
|
+
.exec('bash', [path], {
|
|
276
|
+
cwd,
|
|
277
|
+
timeout: CHECKS_TIMEOUT_SECONDS * 1000,
|
|
278
|
+
signal
|
|
279
|
+
})
|
|
280
|
+
.catch(() => ({ stdout: '', stderr: 'checks could not run', code: 1, killed: false }))
|
|
281
|
+
const tail = `${result.stdout}\n${result.stderr}`.trim().split('\n').slice(-CHECKS_TAIL_LINES).join('\n')
|
|
298
282
|
return { passed: result.code === 0 && !result.killed, tail }
|
|
299
283
|
}
|
|
300
284
|
|
|
301
|
-
function secondarySummary(
|
|
302
|
-
state: LogState,
|
|
303
|
-
samples: readonly PendingSample[],
|
|
304
|
-
config: ResearchConfig
|
|
305
|
-
): string | null {
|
|
285
|
+
function secondarySummary(state: LogState, samples: readonly PendingSample[], config: ResearchConfig): string | null {
|
|
306
286
|
const extras = samples.filter((sample) => sample.name !== config.metric).slice(0, 4)
|
|
307
287
|
if (extras.length === 0) return null
|
|
308
288
|
const baseline = runsInSegment(state)[0]?.metrics ?? {}
|
|
309
289
|
return extras
|
|
310
290
|
.map((sample) => {
|
|
311
291
|
const reference = baseline[sample.name]
|
|
312
|
-
const delta =
|
|
292
|
+
const delta =
|
|
293
|
+
reference === undefined || reference === 0 ? '' : ` (${formatPercent(sample.value, reference)})`
|
|
313
294
|
return `${sample.name} ${formatMetric(sample.value, inferUnit(sample.name))}${delta}`
|
|
314
295
|
})
|
|
315
296
|
.join(' | ')
|
|
@@ -365,7 +346,7 @@ export function createRunTool(pi: GitApi, hooks: ExperimentHooks) {
|
|
|
365
346
|
promptSnippet: 'Run the benchmark once and show the measurement',
|
|
366
347
|
promptGuidelines: [
|
|
367
348
|
'Run this after editing files and before log_experiment. It never changes git history.',
|
|
368
|
-
|
|
349
|
+
"Read the benchmark output, the secondary metrics and the noise advice before deciding. The decision is yours, not the tool's.",
|
|
369
350
|
'If a run is already pending, call log_experiment first. One pending run at a time.'
|
|
370
351
|
],
|
|
371
352
|
parameters: RunParams,
|
|
@@ -531,6 +512,20 @@ export function createLogTool(pi: GitApi, hooks: ExperimentHooks) {
|
|
|
531
512
|
const cwd = ctx.cwd
|
|
532
513
|
const setup = sessionSetup(cwd)
|
|
533
514
|
if (setup.kind === 'error') return errorResult(setup.message)
|
|
515
|
+
const lockProblem = ensureLock(ctx)
|
|
516
|
+
if (lockProblem !== null) return errorResult(lockProblem)
|
|
517
|
+
const activeLock = readLock(cwd)
|
|
518
|
+
const current = await git.currentBranch(pi, cwd)
|
|
519
|
+
if (
|
|
520
|
+
activeLock !== null &&
|
|
521
|
+
activeLock.branch.length > 0 &&
|
|
522
|
+
current.length > 0 &&
|
|
523
|
+
current !== activeLock.branch
|
|
524
|
+
) {
|
|
525
|
+
return errorResult(
|
|
526
|
+
`The worktree moved from ${activeLock.branch} to ${current}. Check out ${activeLock.branch} before logging a keep.`
|
|
527
|
+
)
|
|
528
|
+
}
|
|
534
529
|
const config = setup.config
|
|
535
530
|
const pending = readPending(cwd)
|
|
536
531
|
if (pending === null) {
|
package/src/git.ts
CHANGED
|
@@ -85,24 +85,14 @@ export async function stagePaths(pi: GitApi, cwd: string, files: readonly string
|
|
|
85
85
|
return git(pi, cwd, ['add', '--', ...files])
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export async function commitStaged(
|
|
89
|
-
pi: GitApi,
|
|
90
|
-
cwd: string,
|
|
91
|
-
subject: string,
|
|
92
|
-
body: string
|
|
93
|
-
): Promise<CommitResult> {
|
|
88
|
+
export async function commitStaged(pi: GitApi, cwd: string, subject: string, body: string): Promise<CommitResult> {
|
|
94
89
|
const result = await git(pi, cwd, ['commit', '-m', subject, '-m', body])
|
|
95
90
|
if (result.code !== 0) return { sha: '', output: result.output }
|
|
96
91
|
const sha = await git(pi, cwd, ['rev-parse', '--short=7', 'HEAD'])
|
|
97
92
|
return sha.code === 0 ? { sha: sha.output, output: '' } : { sha: '', output: sha.output }
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
export async function commitAllowEmpty(
|
|
101
|
-
pi: GitApi,
|
|
102
|
-
cwd: string,
|
|
103
|
-
subject: string,
|
|
104
|
-
body: string
|
|
105
|
-
): Promise<GitResult> {
|
|
95
|
+
export async function commitAllowEmpty(pi: GitApi, cwd: string, subject: string, body: string): Promise<GitResult> {
|
|
106
96
|
return git(pi, cwd, ['commit', '--allow-empty', '-m', subject, '-m', body])
|
|
107
97
|
}
|
|
108
98
|
|
|
@@ -130,8 +120,15 @@ export async function restoreFiles(pi: GitApi, cwd: string, files: readonly Pend
|
|
|
130
120
|
return failure
|
|
131
121
|
}
|
|
132
122
|
|
|
133
|
-
export async function
|
|
134
|
-
|
|
123
|
+
export async function repoName(pi: GitApi, cwd: string): Promise<string> {
|
|
124
|
+
const origin = await git(pi, cwd, ['remote', 'get-url', 'origin'])
|
|
125
|
+
if (origin.code === 0 && origin.output.length > 0) return path.basename(origin.output).replace(/\.git$/, '')
|
|
126
|
+
const top = await git(pi, cwd, ['rev-parse', '--show-toplevel'])
|
|
127
|
+
return path.basename(top.output)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function loopWorktreePath(repo: string, branch: string): string {
|
|
131
|
+
return path.join(os.homedir(), 'worktrees', repo, branch.replace(/\//g, '-'))
|
|
135
132
|
}
|
|
136
133
|
|
|
137
134
|
export async function branchExists(pi: GitApi, cwd: string, name: string): Promise<boolean> {
|
|
@@ -154,12 +151,8 @@ export async function mergeBase(pi: GitApi, cwd: string, trunk: string): Promise
|
|
|
154
151
|
}
|
|
155
152
|
|
|
156
153
|
export async function filesInCommit(pi: GitApi, cwd: string, commit: string): Promise<readonly string[]> {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
return result.output
|
|
160
|
-
.split('\n')
|
|
161
|
-
.map((line) => line.trim())
|
|
162
|
-
.filter((line) => line.length > 0 && !line.startsWith(`${RESEARCH_DIRECTORY}/`))
|
|
154
|
+
const files = await zeroList(pi, cwd, ['show', '--name-only', '--format=', commit])
|
|
155
|
+
return files.filter((file) => !file.startsWith(`${RESEARCH_DIRECTORY}/`))
|
|
163
156
|
}
|
|
164
157
|
|
|
165
158
|
export async function fileInCommit(pi: GitApi, cwd: string, commit: string, file: string): Promise<boolean> {
|
package/src/index.ts
CHANGED
|
@@ -229,8 +229,10 @@ export default function autoresearchExtension(pi: ExtensionAPI): void {
|
|
|
229
229
|
}
|
|
230
230
|
if (branch.length > 0 && branch !== trunk) return null
|
|
231
231
|
const name = `autoresearch/${slugify(config?.name ?? goal, 30)}-${branchStamp()}`
|
|
232
|
-
const
|
|
233
|
-
|
|
232
|
+
const directory = git.loopWorktreePath(await git.repoName(pi, ctx.cwd), name)
|
|
233
|
+
const created = await git.worktreeAddBranch(pi, ctx.cwd, directory, name, 'HEAD')
|
|
234
|
+
if (created.code !== 0) return `Could not create branch ${name}: ${created.output}`
|
|
235
|
+
return `Autoresearch runs in its own worktree: ${directory}. This checkout stays on ${branch}. Start pi in that directory and run /autoresearch there.`
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
const activate = async (ctx: ExtensionCommandContext, text: string): Promise<void> => {
|
package/src/session.ts
CHANGED
|
@@ -29,9 +29,7 @@ const isLockSchema = new Validator(LockSchema).isValidObject
|
|
|
29
29
|
|
|
30
30
|
export type SessionLock = Infer<typeof LockSchema>
|
|
31
31
|
|
|
32
|
-
export type LockResult =
|
|
33
|
-
| { kind: 'ok'; lock: SessionLock }
|
|
34
|
-
| { kind: 'other'; lock: SessionLock }
|
|
32
|
+
export type LockResult = { kind: 'ok'; lock: SessionLock } | { kind: 'other'; lock: SessionLock }
|
|
35
33
|
|
|
36
34
|
export function researchPath(cwd: string, file: string): string {
|
|
37
35
|
return path.join(cwd, RESEARCH_DIRECTORY, file)
|
|
@@ -143,12 +141,18 @@ function writeLock(cwd: string, lock: SessionLock): void {
|
|
|
143
141
|
fs.renameSync(temporary, target)
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
cwd
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
function createLock(cwd: string, lock: SessionLock): boolean {
|
|
145
|
+
const directory = path.join(cwd, RESEARCH_DIRECTORY)
|
|
146
|
+
fs.mkdirSync(directory, { recursive: true })
|
|
147
|
+
try {
|
|
148
|
+
fs.writeFileSync(lockPath(cwd), `${JSON.stringify(lock)}\n`, { flag: 'wx' })
|
|
149
|
+
return true
|
|
150
|
+
} catch {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function acquireLock(cwd: string, sessionId: string, branch: string, dirty: readonly string[]): LockResult {
|
|
152
156
|
const existing = readLock(cwd)
|
|
153
157
|
if (existing !== null && existing.sessionId !== sessionId && pidAlive(existing.pid)) {
|
|
154
158
|
return { kind: 'other', lock: existing }
|
|
@@ -161,8 +165,18 @@ export function acquireLock(
|
|
|
161
165
|
startedAt: sameSession ? existing.startedAt : Date.now(),
|
|
162
166
|
dirty: sameSession ? existing.dirty : [...dirty]
|
|
163
167
|
}
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
if (sameSession) {
|
|
169
|
+
writeLock(cwd, lock)
|
|
170
|
+
return { kind: 'ok', lock }
|
|
171
|
+
}
|
|
172
|
+
if (createLock(cwd, lock)) return { kind: 'ok', lock }
|
|
173
|
+
const latest = readLock(cwd)
|
|
174
|
+
if (latest !== null && latest.sessionId !== sessionId && pidAlive(latest.pid)) {
|
|
175
|
+
return { kind: 'other', lock: latest }
|
|
176
|
+
}
|
|
177
|
+
fs.rmSync(lockPath(cwd), { force: true })
|
|
178
|
+
if (createLock(cwd, lock)) return { kind: 'ok', lock }
|
|
179
|
+
return { kind: 'other', lock: readLock(cwd) ?? lock }
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
export function releaseLock(cwd: string, sessionId: string): void {
|