@ivotoby/postgram-cli 1.38.3 → 1.39.0
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 +26 -0
- package/dist/client.js +4 -1
- package/dist/pgm.js +12 -0
- package/dist/tracking/api.js +139 -0
- package/dist/tracking/config.js +148 -0
- package/dist/tracking/feature-commands.js +228 -0
- package/dist/tracking/here.js +267 -0
- package/dist/tracking/hooks.js +375 -0
- package/dist/tracking/local.js +175 -0
- package/dist/tracking/resume.js +43 -0
- package/dist/tracking/scan-discovery.js +295 -0
- package/dist/tracking/scan.js +261 -0
- package/dist/tracking/types.js +14 -0
- package/dist/tracking/where.js +231 -0
- package/dist/tracking/worktree.js +187 -0
- package/integrations/pi/postgram.ts +73 -0
- package/package.json +2 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// cli/src/tracking/local.ts: local machine/workspace resolution (research R6-R8).
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
export const runGit = (args, options) => new Promise((resolve) => {
|
|
7
|
+
execFile('git', args, {
|
|
8
|
+
cwd: options.cwd,
|
|
9
|
+
timeout: options.timeoutMs,
|
|
10
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }
|
|
11
|
+
}, (error, stdout) => {
|
|
12
|
+
if (error !== null) {
|
|
13
|
+
resolve({ ok: false, timedOut: error.killed === true });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
resolve({ ok: true, stdout: stdout.replace(/\n$/, '') });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
function parseCount(stdout) {
|
|
21
|
+
const value = Number.parseInt(stdout.trim(), 10);
|
|
22
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
23
|
+
}
|
|
24
|
+
export async function resolveLocalWorkspace(cwd, options) {
|
|
25
|
+
const budgetMs = options?.budgetMs ?? 800;
|
|
26
|
+
const run = options?.run ?? runGit;
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
const nextTimeoutMs = () => Math.max(1, Math.min(500, budgetMs - (Date.now() - startedAt)));
|
|
29
|
+
const toplevel = await run(['rev-parse', '--show-toplevel'], {
|
|
30
|
+
cwd,
|
|
31
|
+
timeoutMs: nextTimeoutMs()
|
|
32
|
+
});
|
|
33
|
+
if (toplevel.ok === false) {
|
|
34
|
+
if (toplevel.timedOut) {
|
|
35
|
+
return { kind: 'timeout' };
|
|
36
|
+
}
|
|
37
|
+
const outside = await realpath(cwd);
|
|
38
|
+
return {
|
|
39
|
+
kind: 'ok',
|
|
40
|
+
path: outside,
|
|
41
|
+
repoRoot: null,
|
|
42
|
+
workspace: { path: outside, git: null }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const root = await realpath(toplevel.stdout);
|
|
46
|
+
const commonDir = run(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, timeoutMs: nextTimeoutMs() });
|
|
47
|
+
const branch = run(['symbolic-ref', '--short', '-q', 'HEAD'], {
|
|
48
|
+
cwd: root,
|
|
49
|
+
timeoutMs: nextTimeoutMs()
|
|
50
|
+
});
|
|
51
|
+
const remote = run(['remote', 'get-url', 'origin'], {
|
|
52
|
+
cwd: root,
|
|
53
|
+
timeoutMs: nextTimeoutMs()
|
|
54
|
+
});
|
|
55
|
+
const status = run(['status', '--porcelain=v1', '--untracked-files=normal'], { cwd: root, timeoutMs: nextTimeoutMs() });
|
|
56
|
+
const upstream = run(['rev-list', '--count', '@{upstream}..HEAD'], {
|
|
57
|
+
cwd: root,
|
|
58
|
+
timeoutMs: nextTimeoutMs()
|
|
59
|
+
});
|
|
60
|
+
const [commonDirResult, branchResult, remoteResult, statusResult, upstreamResult] = await Promise.all([commonDir, branch, remote, status, upstream]);
|
|
61
|
+
const workspace = { path: root };
|
|
62
|
+
let repoRoot = null;
|
|
63
|
+
if (commonDirResult.ok) {
|
|
64
|
+
repoRoot = path.dirname(commonDirResult.stdout);
|
|
65
|
+
workspace.repo_root = repoRoot;
|
|
66
|
+
}
|
|
67
|
+
if (branchResult.ok) {
|
|
68
|
+
workspace.branch = branchResult.stdout === '' ? null : branchResult.stdout;
|
|
69
|
+
}
|
|
70
|
+
else if (branchResult.timedOut === false) {
|
|
71
|
+
workspace.branch = null;
|
|
72
|
+
}
|
|
73
|
+
if (remoteResult.ok) {
|
|
74
|
+
workspace.repo_remote = remoteResult.stdout;
|
|
75
|
+
}
|
|
76
|
+
else if (remoteResult.timedOut === false) {
|
|
77
|
+
workspace.repo_remote = null;
|
|
78
|
+
}
|
|
79
|
+
let git = null;
|
|
80
|
+
if (statusResult.ok) {
|
|
81
|
+
const dirtyCount = statusResult.stdout
|
|
82
|
+
.split('\n')
|
|
83
|
+
.filter((line) => line !== '')
|
|
84
|
+
.length;
|
|
85
|
+
if (upstreamResult.ok) {
|
|
86
|
+
const unpushed = parseCount(upstreamResult.stdout);
|
|
87
|
+
if (unpushed !== null) {
|
|
88
|
+
git = { dirty_count: dirtyCount, unpushed_count: unpushed, has_upstream: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (upstreamResult.timedOut === false) {
|
|
92
|
+
const fallback = await run(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { cwd: root, timeoutMs: nextTimeoutMs() });
|
|
93
|
+
if (fallback.ok) {
|
|
94
|
+
const unpushed = parseCount(fallback.stdout);
|
|
95
|
+
if (unpushed !== null) {
|
|
96
|
+
git = { dirty_count: dirtyCount, unpushed_count: unpushed, has_upstream: false };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (git !== null) {
|
|
102
|
+
workspace.git = git;
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'ok', path: root, repoRoot, workspace };
|
|
105
|
+
}
|
|
106
|
+
function defaultExec(cmd, args) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
execFile(cmd, args, (error, stdout) => {
|
|
109
|
+
if (error !== null) {
|
|
110
|
+
reject(error instanceof Error ? error : new Error('exec failed'));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolve(stdout);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export async function readBootId(options) {
|
|
119
|
+
const platform = options?.platform ?? process.platform;
|
|
120
|
+
const readFileFn = options?.readFile ?? ((file) => readFile(file, 'utf8'));
|
|
121
|
+
const execFn = options?.exec ?? defaultExec;
|
|
122
|
+
try {
|
|
123
|
+
if (platform === 'linux') {
|
|
124
|
+
return (await readFileFn('/proc/sys/kernel/random/boot_id')).trim();
|
|
125
|
+
}
|
|
126
|
+
if (platform === 'darwin') {
|
|
127
|
+
const output = await execFn('sysctl', ['-n', 'kern.boottime']);
|
|
128
|
+
const match = output.match(/\{[^}]*\}/);
|
|
129
|
+
if (match === null || match[0] === undefined) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return createHash('sha256').update(match[0]).digest('hex');
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export const MARKER_DIR = '.postgram';
|
|
141
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
142
|
+
function markerFile(worktree) {
|
|
143
|
+
return path.join(worktree, MARKER_DIR, 'feature.json');
|
|
144
|
+
}
|
|
145
|
+
export async function readMarker(worktree) {
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await readFile(markerFile(worktree), 'utf8');
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(text);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const id = parsed.feature_id;
|
|
164
|
+
return typeof id === 'string' && UUID_RE.test(id) ? id : null;
|
|
165
|
+
}
|
|
166
|
+
export async function writeMarker(worktree, featureId, now) {
|
|
167
|
+
const dir = path.join(worktree, MARKER_DIR);
|
|
168
|
+
await mkdir(dir, { recursive: true });
|
|
169
|
+
const createdAt = (now ?? new Date()).toISOString();
|
|
170
|
+
await writeFile(path.join(dir, 'feature.json'), `${JSON.stringify({ feature_id: featureId, created_at: createdAt }, null, 2)}\n`, 'utf8');
|
|
171
|
+
await writeFile(path.join(dir, '.gitignore'), '*\n', 'utf8');
|
|
172
|
+
}
|
|
173
|
+
export async function removeMarker(worktree) {
|
|
174
|
+
await rm(markerFile(worktree), { force: true });
|
|
175
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// cli/src/tracking/resume.ts
|
|
2
|
+
import { shq } from './config.js';
|
|
3
|
+
export function resumeDirectory(workspace) {
|
|
4
|
+
const cwd = workspace.resume_session?.cwd ?? null;
|
|
5
|
+
if (cwd !== null && (cwd === workspace.path || cwd.startsWith(`${workspace.path}/`))) {
|
|
6
|
+
return cwd;
|
|
7
|
+
}
|
|
8
|
+
return workspace.path;
|
|
9
|
+
}
|
|
10
|
+
export function isTranscriptGone(workspace, localAlias, exists) {
|
|
11
|
+
const session = workspace.resume_session;
|
|
12
|
+
return (workspace.machine === localAlias &&
|
|
13
|
+
session !== null &&
|
|
14
|
+
session.agent_kind === 'claude-code' &&
|
|
15
|
+
session.transcript_path !== null &&
|
|
16
|
+
!exists(session.transcript_path));
|
|
17
|
+
}
|
|
18
|
+
export function renderResume(workspace, input) {
|
|
19
|
+
const session = workspace.resume_session;
|
|
20
|
+
const first = workspace.sessions[0];
|
|
21
|
+
const kind = session?.agent_kind ?? first?.agent_kind ?? 'claude-code';
|
|
22
|
+
const withId = session ? input.templates[session.agent_kind].with_id : null;
|
|
23
|
+
const tpl = session !== null && withId !== null && !input.transcriptGone
|
|
24
|
+
? withId.replaceAll('{session_id}', shq(session.agent_session_id))
|
|
25
|
+
: input.templates[kind].without_id;
|
|
26
|
+
const local = `cd ${shq(resumeDirectory(workspace))}${tpl ? ` && ${tpl}` : ''}`;
|
|
27
|
+
if (workspace.machine === input.localAlias) {
|
|
28
|
+
return local;
|
|
29
|
+
}
|
|
30
|
+
return `ssh -t ${workspace.connect_target} ${shq(local)}`;
|
|
31
|
+
}
|
|
32
|
+
export function formatAge(fromIso, now) {
|
|
33
|
+
const elapsedMs = Math.max(0, now.getTime() - Date.parse(fromIso));
|
|
34
|
+
const minutes = Math.floor(elapsedMs / 60_000);
|
|
35
|
+
if (minutes < 60) {
|
|
36
|
+
return `${minutes}m`;
|
|
37
|
+
}
|
|
38
|
+
const hours = Math.floor(elapsedMs / 3_600_000);
|
|
39
|
+
if (hours < 48) {
|
|
40
|
+
return `${hours}h`;
|
|
41
|
+
}
|
|
42
|
+
return `${Math.floor(elapsedMs / 86_400_000)}d`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// cli/src/tracking/scan-discovery.ts: pgm scan repository and session discovery (research R9).
|
|
2
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { runGit } from './local.js';
|
|
5
|
+
async function isDirectory(file) {
|
|
6
|
+
try {
|
|
7
|
+
return (await stat(file)).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function hasGitEntry(dir) {
|
|
14
|
+
try {
|
|
15
|
+
await stat(path.join(dir, '.git'));
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function findRepositories(roots, depth) {
|
|
23
|
+
const found = new Set();
|
|
24
|
+
const visit = async (dir, level) => {
|
|
25
|
+
if (await hasGitEntry(dir)) {
|
|
26
|
+
found.add(await realpath(dir));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (level >= depth) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const name of entries) {
|
|
40
|
+
if (name === 'node_modules' || name.startsWith('.')) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const child = path.join(dir, name);
|
|
44
|
+
if (await isDirectory(child)) {
|
|
45
|
+
await visit(child, level + 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
for (const root of roots) {
|
|
50
|
+
let resolved;
|
|
51
|
+
try {
|
|
52
|
+
resolved = await realpath(root);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!(await isDirectory(resolved))) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
await visit(resolved, 0);
|
|
61
|
+
}
|
|
62
|
+
return [...found].sort();
|
|
63
|
+
}
|
|
64
|
+
export async function listWorktrees(repo, run) {
|
|
65
|
+
const runner = run ?? runGit;
|
|
66
|
+
const result = await runner(['worktree', 'list', '--porcelain'], {
|
|
67
|
+
cwd: repo,
|
|
68
|
+
timeoutMs: 5000
|
|
69
|
+
});
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const block of result.stdout.split('\n\n')) {
|
|
75
|
+
const trimmed = block.trim();
|
|
76
|
+
if (trimmed === '') {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let worktree = null;
|
|
80
|
+
let branch = null;
|
|
81
|
+
let bare = false;
|
|
82
|
+
for (const line of trimmed.split('\n')) {
|
|
83
|
+
if (line.startsWith('worktree ')) {
|
|
84
|
+
worktree = line.slice('worktree '.length);
|
|
85
|
+
}
|
|
86
|
+
else if (line.startsWith('branch refs/heads/')) {
|
|
87
|
+
branch = line.slice('branch refs/heads/'.length);
|
|
88
|
+
}
|
|
89
|
+
else if (line === 'bare') {
|
|
90
|
+
bare = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (worktree === null || bare) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!(await isDirectory(worktree))) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
out.push({ path: await realpath(worktree), branch });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
export async function discoverWorktrees(roots, depth, run) {
|
|
104
|
+
const repos = await findRepositories(roots, depth);
|
|
105
|
+
const byPath = new Map();
|
|
106
|
+
for (const repo of repos) {
|
|
107
|
+
for (const worktree of await listWorktrees(repo, run)) {
|
|
108
|
+
byPath.set(worktree.path, worktree);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
export function claudeEncode(value) {
|
|
114
|
+
return value.replace(/[^a-zA-Z0-9]/g, '-');
|
|
115
|
+
}
|
|
116
|
+
function bySessionId(a, b) {
|
|
117
|
+
return a.agent_session_id < b.agent_session_id
|
|
118
|
+
? -1
|
|
119
|
+
: a.agent_session_id > b.agent_session_id
|
|
120
|
+
? 1
|
|
121
|
+
: 0;
|
|
122
|
+
}
|
|
123
|
+
function lineCwd(line) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
if (trimmed === '') {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(trimmed);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const cwd = parsed.cwd;
|
|
139
|
+
return typeof cwd === 'string' ? cwd : null;
|
|
140
|
+
}
|
|
141
|
+
async function claudeSession(transcript, worktree) {
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
text = await readFile(transcript, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
let cwd = null;
|
|
150
|
+
for (const line of text.split('\n').slice(0, 200)) {
|
|
151
|
+
const value = lineCwd(line);
|
|
152
|
+
if (value !== null) {
|
|
153
|
+
cwd = value;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (cwd !== null && cwd !== worktree && !cwd.startsWith(worktree + '/')) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const mtime = (await stat(transcript)).mtime;
|
|
161
|
+
return {
|
|
162
|
+
agent_kind: 'claude-code',
|
|
163
|
+
agent_session_id: path.basename(transcript, '.jsonl'),
|
|
164
|
+
cwd,
|
|
165
|
+
transcript_path: transcript,
|
|
166
|
+
observed_at: mtime.toISOString()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export async function findClaudeSessions(worktrees, claudeDir) {
|
|
170
|
+
const notes = [];
|
|
171
|
+
const encs = new Map();
|
|
172
|
+
for (const worktree of worktrees) {
|
|
173
|
+
const enc = claudeEncode(worktree);
|
|
174
|
+
if (enc.length > 200) {
|
|
175
|
+
notes.push(`skipped ${worktree}: encoded path too long`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
encs.set(worktree, enc);
|
|
179
|
+
}
|
|
180
|
+
const sessions = new Map();
|
|
181
|
+
const projectsDir = path.join(claudeDir, 'projects');
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = await readdir(projectsDir);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { sessions, notes };
|
|
188
|
+
}
|
|
189
|
+
for (const name of entries) {
|
|
190
|
+
const dir = path.join(projectsDir, name);
|
|
191
|
+
if (!(await isDirectory(dir))) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
let best = null;
|
|
195
|
+
let bestLen = -1;
|
|
196
|
+
for (const [worktree, enc] of encs) {
|
|
197
|
+
if (name === enc || name.startsWith(enc + '-')) {
|
|
198
|
+
if (enc.length > bestLen) {
|
|
199
|
+
bestLen = enc.length;
|
|
200
|
+
best = worktree;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (best === null) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
let files;
|
|
208
|
+
try {
|
|
209
|
+
files = await readdir(dir);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const found = [];
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
if (!file.endsWith('.jsonl')) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const session = await claudeSession(path.join(dir, file), best);
|
|
220
|
+
if (session !== null) {
|
|
221
|
+
found.push(session);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (found.length > 0) {
|
|
225
|
+
const existing = sessions.get(best) ?? [];
|
|
226
|
+
existing.push(...found);
|
|
227
|
+
existing.sort(bySessionId);
|
|
228
|
+
sessions.set(best, existing);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { sessions, notes };
|
|
232
|
+
}
|
|
233
|
+
async function piSession(transcript) {
|
|
234
|
+
let text;
|
|
235
|
+
try {
|
|
236
|
+
text = await readFile(transcript, 'utf8');
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const first = text.split('\n')[0];
|
|
242
|
+
if (first === undefined || first.trim() === '') {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(first);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const header = parsed;
|
|
256
|
+
if (typeof header.id !== 'string') {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const mtime = (await stat(transcript)).mtime;
|
|
260
|
+
return {
|
|
261
|
+
agent_kind: 'pi',
|
|
262
|
+
agent_session_id: header.id,
|
|
263
|
+
cwd: typeof header.cwd === 'string' ? header.cwd : null,
|
|
264
|
+
transcript_path: transcript,
|
|
265
|
+
observed_at: mtime.toISOString()
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
export async function findPiSessions(worktrees, piDir) {
|
|
269
|
+
const sessions = new Map();
|
|
270
|
+
for (const worktree of worktrees) {
|
|
271
|
+
const dir = path.join(piDir, 'sessions', `--${worktree.replace(/^\/+/, '').replaceAll('/', '-')}--`);
|
|
272
|
+
let files;
|
|
273
|
+
try {
|
|
274
|
+
files = await readdir(dir);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const found = [];
|
|
280
|
+
for (const file of files) {
|
|
281
|
+
if (!file.endsWith('.jsonl')) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const session = await piSession(path.join(dir, file));
|
|
285
|
+
if (session !== null) {
|
|
286
|
+
found.push(session);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (found.length > 0) {
|
|
290
|
+
found.sort(bySessionId);
|
|
291
|
+
sessions.set(worktree, found);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return sessions;
|
|
295
|
+
}
|