@pi-in-go/pigpen-acp 0.1.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/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RequestError } from '@agentclientprotocol/sdk'
|
|
2
|
+
import { getAuthMethods } from './auth.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort detection of missing credentials / not-configured errors from pi/providers.
|
|
6
|
+
*
|
|
7
|
+
* We can't do a full provider-specific check here, so we look for common substrings.
|
|
8
|
+
*/
|
|
9
|
+
export function maybeAuthRequiredError(err: unknown): RequestError | null {
|
|
10
|
+
const msg = String((err as any)?.message ?? err ?? '')
|
|
11
|
+
const s = msg.toLowerCase()
|
|
12
|
+
|
|
13
|
+
const patterns = [
|
|
14
|
+
'api key',
|
|
15
|
+
'apikey',
|
|
16
|
+
'missing key',
|
|
17
|
+
'no key',
|
|
18
|
+
'not configured',
|
|
19
|
+
'unauthorized',
|
|
20
|
+
'authentication',
|
|
21
|
+
'permission denied',
|
|
22
|
+
'forbidden',
|
|
23
|
+
'401',
|
|
24
|
+
'403'
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const hit = patterns.some(p => s.includes(p))
|
|
28
|
+
if (!hit) return null
|
|
29
|
+
|
|
30
|
+
// Include terminal auth method options in error data.
|
|
31
|
+
return RequestError.authRequired(
|
|
32
|
+
{
|
|
33
|
+
authMethods: getAuthMethods()
|
|
34
|
+
},
|
|
35
|
+
'Configure an API key or log in with an OAuth provider.'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AuthMethod } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
export const PI_SETUP_METHOD_ID = 'pi_terminal_login'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zed (and some other clients) currently support "Terminal Auth" via an extension field
|
|
7
|
+
* in AuthMethod._meta, rather than the RFD "type/args/env" shape.
|
|
8
|
+
*
|
|
9
|
+
* We include BOTH for maximum compatibility:
|
|
10
|
+
* - `_meta["terminal-auth"]`: used by Zed to render the "Authenticate" banner + button.
|
|
11
|
+
* - `type/args/env`: registry-required shape.
|
|
12
|
+
*/
|
|
13
|
+
export function getAuthMethods(opts?: { supportsTerminalAuthMeta?: boolean }): AuthMethod[] {
|
|
14
|
+
const supportsTerminalAuthMeta = opts?.supportsTerminalAuthMeta ?? true
|
|
15
|
+
|
|
16
|
+
const method: any = {
|
|
17
|
+
id: PI_SETUP_METHOD_ID,
|
|
18
|
+
name: 'Launch pi in the terminal',
|
|
19
|
+
description: 'Start pi in an interactive terminal to configure API keys or login',
|
|
20
|
+
|
|
21
|
+
// Registry-required fields
|
|
22
|
+
type: 'terminal',
|
|
23
|
+
args: ['--terminal-login'],
|
|
24
|
+
env: {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (supportsTerminalAuthMeta) {
|
|
28
|
+
// Best-effort launch spec for Zed's terminal-auth banner.
|
|
29
|
+
// Zed expects a full command+args (see mistral-vibe implementation).
|
|
30
|
+
const launch = terminalAuthLaunchSpec()
|
|
31
|
+
|
|
32
|
+
method._meta = {
|
|
33
|
+
...(method._meta ?? {}),
|
|
34
|
+
'terminal-auth': {
|
|
35
|
+
...launch,
|
|
36
|
+
label: 'Launch pi'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [method as AuthMethod]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function terminalAuthLaunchSpec(): { command: string; args: string[] } {
|
|
45
|
+
// If we were launched as `node /path/to/dist/index.js`, reuse that.
|
|
46
|
+
// This is the most reliable in local dev and custom Zed configurations.
|
|
47
|
+
const argv0 = process.argv[0] || 'node'
|
|
48
|
+
const argv1 = process.argv[1]
|
|
49
|
+
if (argv1 && argv0) {
|
|
50
|
+
const isNode = argv0.includes('node')
|
|
51
|
+
const isJs = argv1.endsWith('.js')
|
|
52
|
+
if (isNode && isJs) {
|
|
53
|
+
return { command: argv0, args: [argv1, '--terminal-login'] }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fallback: assume `pi-acp` is on PATH.
|
|
58
|
+
return { command: 'pi-acp', args: ['--terminal-login'] }
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Storage owned by the ACP adapter.
|
|
6
|
+
*
|
|
7
|
+
* We intentionally keep this separate from pi's own ~/.pi/agent/* directory.
|
|
8
|
+
*/
|
|
9
|
+
export function getPiAcpDir(): string {
|
|
10
|
+
return join(homedir(), '.pi', 'pi-acp')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getPiAcpSessionMapPath(): string {
|
|
14
|
+
return join(getPiAcpDir(), 'session-map.json')
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AvailableCommand } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
export type PiRpcCommandInfo = {
|
|
4
|
+
name?: unknown
|
|
5
|
+
description?: unknown
|
|
6
|
+
source?: unknown
|
|
7
|
+
location?: unknown
|
|
8
|
+
path?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function describeFallback(c: PiRpcCommandInfo): string {
|
|
12
|
+
const source = typeof c.source === 'string' ? c.source : ''
|
|
13
|
+
const location = typeof c.location === 'string' ? c.location : ''
|
|
14
|
+
|
|
15
|
+
const parts: string[] = []
|
|
16
|
+
if (source) parts.push(source)
|
|
17
|
+
if (location) parts.push(location)
|
|
18
|
+
|
|
19
|
+
return parts.length ? `(${parts.join(':')})` : '(command)'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function toAvailableCommandsFromPiGetCommands(
|
|
23
|
+
data: unknown,
|
|
24
|
+
opts?: { enableSkillCommands?: boolean; includeExtensionCommands?: boolean }
|
|
25
|
+
): {
|
|
26
|
+
commands: AvailableCommand[]
|
|
27
|
+
raw: PiRpcCommandInfo[]
|
|
28
|
+
} {
|
|
29
|
+
const enableSkillCommands = opts?.enableSkillCommands ?? true
|
|
30
|
+
const includeExtensionCommands = opts?.includeExtensionCommands ?? false
|
|
31
|
+
|
|
32
|
+
const root: any = data
|
|
33
|
+
const commandsRaw: PiRpcCommandInfo[] = Array.isArray(root?.commands)
|
|
34
|
+
? root.commands
|
|
35
|
+
: Array.isArray(root?.data?.commands)
|
|
36
|
+
? root.data.commands
|
|
37
|
+
: []
|
|
38
|
+
|
|
39
|
+
const out: AvailableCommand[] = []
|
|
40
|
+
|
|
41
|
+
for (const c of commandsRaw) {
|
|
42
|
+
const name = typeof c?.name === 'string' ? c.name.trim() : ''
|
|
43
|
+
if (!name) continue
|
|
44
|
+
|
|
45
|
+
const source = typeof c?.source === 'string' ? c.source : ''
|
|
46
|
+
if (!includeExtensionCommands && source === 'extension') continue
|
|
47
|
+
|
|
48
|
+
if (!enableSkillCommands && name.startsWith('skill:')) continue
|
|
49
|
+
|
|
50
|
+
const desc = typeof c?.description === 'string' ? c.description.trim() : ''
|
|
51
|
+
|
|
52
|
+
out.push({
|
|
53
|
+
name,
|
|
54
|
+
description: desc || describeFallback(c)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { commands: out, raw: commandsRaw }
|
|
59
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, openSync, readSync, closeSync, existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, resolve, isAbsolute } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export type PiSessionListItem = {
|
|
6
|
+
sessionId: string
|
|
7
|
+
cwd: string
|
|
8
|
+
title: string | null
|
|
9
|
+
updatedAt: string | null
|
|
10
|
+
sessionFile: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TAIL_BYTES = 256 * 1024
|
|
14
|
+
const DEFAULT_HEAD_BYTES = 64 * 1024
|
|
15
|
+
|
|
16
|
+
function getPiAgentDir(): string {
|
|
17
|
+
// pi supports overriding config dir via PI_CODING_AGENT_DIR.
|
|
18
|
+
// See pi README.
|
|
19
|
+
return process.env.PI_CODING_AGENT_DIR ? resolve(process.env.PI_CODING_AGENT_DIR) : join(homedir(), '.pi', 'agent')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readSessionDirFromSettings(agentDir: string): string | null {
|
|
23
|
+
const settingsPath = join(agentDir, 'settings.json')
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(settingsPath)) return null
|
|
26
|
+
const raw = readFileSync(settingsPath, 'utf8')
|
|
27
|
+
const data = JSON.parse(raw) as unknown
|
|
28
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
|
29
|
+
|
|
30
|
+
const sessionDir = (data as Record<string, unknown>).sessionDir
|
|
31
|
+
if (typeof sessionDir !== 'string' || !sessionDir.trim()) return null
|
|
32
|
+
|
|
33
|
+
return isAbsolute(sessionDir) ? sessionDir : resolve(agentDir, sessionDir)
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getPiSessionsDir(): string {
|
|
40
|
+
const agentDir = getPiAgentDir()
|
|
41
|
+
return readSessionDirFromSettings(agentDir) ?? join(agentDir, 'sessions')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walkJsonlFiles(dir: string, out: string[]) {
|
|
45
|
+
let entries: import('node:fs').Dirent[]
|
|
46
|
+
try {
|
|
47
|
+
// Force string names.
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) as unknown as import('node:fs').Dirent[]
|
|
49
|
+
} catch {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const name = typeof (e as any).name === 'string' ? (e as any).name : String((e as any).name)
|
|
55
|
+
const p = join(dir, name)
|
|
56
|
+
if (e.isDirectory()) walkJsonlFiles(p, out)
|
|
57
|
+
else if (e.isFile() && name.endsWith('.jsonl')) out.push(p)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readFirstLine(path: string): string | null {
|
|
62
|
+
// Avoid reading the whole file.
|
|
63
|
+
const fd = openSync(path, 'r')
|
|
64
|
+
try {
|
|
65
|
+
const buf = Buffer.alloc(DEFAULT_HEAD_BYTES)
|
|
66
|
+
const n = readSync(fd, buf, 0, buf.length, 0)
|
|
67
|
+
if (n <= 0) return null
|
|
68
|
+
const s = buf.subarray(0, n).toString('utf-8')
|
|
69
|
+
const idx = s.indexOf('\n')
|
|
70
|
+
return idx === -1 ? s.trim() : s.slice(0, idx).trim()
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
} finally {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd)
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readTail(path: string, tailBytes = DEFAULT_TAIL_BYTES): string {
|
|
83
|
+
const st = statSync(path)
|
|
84
|
+
const start = Math.max(0, st.size - tailBytes)
|
|
85
|
+
const len = st.size - start
|
|
86
|
+
|
|
87
|
+
const fd = openSync(path, 'r')
|
|
88
|
+
try {
|
|
89
|
+
const buf = Buffer.alloc(len)
|
|
90
|
+
const n = readSync(fd, buf, 0, buf.length, start)
|
|
91
|
+
return buf.subarray(0, n).toString('utf-8')
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
closeSync(fd)
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseSessionHeader(firstLine: string): { sessionId: string; cwd: string } | null {
|
|
102
|
+
try {
|
|
103
|
+
const obj = JSON.parse(firstLine) as any
|
|
104
|
+
if (obj?.type !== 'session') return null
|
|
105
|
+
const sessionId = typeof obj?.id === 'string' ? obj.id : null
|
|
106
|
+
const cwd = typeof obj?.cwd === 'string' ? obj.cwd : null
|
|
107
|
+
if (!sessionId || !cwd) return null
|
|
108
|
+
return { sessionId, cwd }
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pickTitleFromTail(tail: string): string | null {
|
|
115
|
+
// Try to find the *latest* session_info entry (stores the user-provided name).
|
|
116
|
+
// We scan backwards line-by-line.
|
|
117
|
+
const lines = tail.split(/\r?\n/)
|
|
118
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
119
|
+
const line = lines[i].trim()
|
|
120
|
+
if (!line) continue
|
|
121
|
+
try {
|
|
122
|
+
const obj = JSON.parse(line) as any
|
|
123
|
+
if (obj?.type === 'session_info' && typeof obj?.name === 'string' && obj.name.trim()) {
|
|
124
|
+
return obj.name.trim()
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scanSessionInfoNameFromFile(path: string): string | null {
|
|
134
|
+
// Fallback when the session_info entry is older than our tail window.
|
|
135
|
+
// Scan the whole file line-by-line and remember the last session_info.name.
|
|
136
|
+
const fd = openSync(path, 'r')
|
|
137
|
+
try {
|
|
138
|
+
const buf = Buffer.alloc(256 * 1024)
|
|
139
|
+
let leftover = ''
|
|
140
|
+
let offset = 0
|
|
141
|
+
let lastName: string | null = null
|
|
142
|
+
|
|
143
|
+
while (true) {
|
|
144
|
+
const n = readSync(fd, buf, 0, buf.length, offset)
|
|
145
|
+
if (n <= 0) break
|
|
146
|
+
offset += n
|
|
147
|
+
|
|
148
|
+
const chunk = leftover + buf.subarray(0, n).toString('utf8')
|
|
149
|
+
const lines = chunk.split(/\r?\n/)
|
|
150
|
+
leftover = lines.pop() ?? ''
|
|
151
|
+
|
|
152
|
+
for (const line0 of lines) {
|
|
153
|
+
const line = line0.trim()
|
|
154
|
+
if (!line) continue
|
|
155
|
+
try {
|
|
156
|
+
const obj = JSON.parse(line) as any
|
|
157
|
+
if (obj?.type === 'session_info' && typeof obj?.name === 'string' && obj.name.trim()) {
|
|
158
|
+
lastName = obj.name.trim()
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Best-effort: parse leftover if it was a full line without trailing newline.
|
|
167
|
+
const tailLine = leftover.trim()
|
|
168
|
+
if (tailLine) {
|
|
169
|
+
try {
|
|
170
|
+
const obj = JSON.parse(tailLine) as any
|
|
171
|
+
if (obj?.type === 'session_info' && typeof obj?.name === 'string' && obj.name.trim()) {
|
|
172
|
+
lastName = obj.name.trim()
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return lastName
|
|
180
|
+
} catch {
|
|
181
|
+
return null
|
|
182
|
+
} finally {
|
|
183
|
+
try {
|
|
184
|
+
closeSync(fd)
|
|
185
|
+
} catch {
|
|
186
|
+
// ignore
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pickUpdatedAtFromTail(tail: string): string | null {
|
|
192
|
+
// pi's `/resume` effectively orders sessions by last *message* activity.
|
|
193
|
+
// We scan backwards and pick the timestamp of the most recent entry with type === "message".
|
|
194
|
+
const lines = tail.split(/\r?\n/)
|
|
195
|
+
|
|
196
|
+
// 1) Prefer the most recent message entry.
|
|
197
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
198
|
+
const line = lines[i].trim()
|
|
199
|
+
if (!line) continue
|
|
200
|
+
try {
|
|
201
|
+
const obj = JSON.parse(line) as any
|
|
202
|
+
if (obj?.type !== 'message') continue
|
|
203
|
+
const ts = typeof obj?.timestamp === 'string' ? obj.timestamp : null
|
|
204
|
+
if (!ts) continue
|
|
205
|
+
const d = new Date(ts)
|
|
206
|
+
if (Number.isFinite(d.getTime())) return d.toISOString()
|
|
207
|
+
} catch {
|
|
208
|
+
// ignore
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2) Fallback: any valid timestamp (covers sessions that somehow have no messages).
|
|
213
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
214
|
+
const line = lines[i].trim()
|
|
215
|
+
if (!line) continue
|
|
216
|
+
try {
|
|
217
|
+
const obj = JSON.parse(line) as any
|
|
218
|
+
const ts = typeof obj?.timestamp === 'string' ? obj.timestamp : null
|
|
219
|
+
if (!ts) continue
|
|
220
|
+
const d = new Date(ts)
|
|
221
|
+
if (Number.isFinite(d.getTime())) return d.toISOString()
|
|
222
|
+
} catch {
|
|
223
|
+
// ignore
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pickFallbackTitleFromHead(path: string): string | null {
|
|
231
|
+
// Fallback to first user message.
|
|
232
|
+
// NOTE: we keep this simple: read a small head chunk and parse line-by-line.
|
|
233
|
+
try {
|
|
234
|
+
const raw = readFileSync(path, { encoding: 'utf8' })
|
|
235
|
+
const lines = raw.split(/\r?\n/)
|
|
236
|
+
for (const line0 of lines) {
|
|
237
|
+
const line = line0.trim()
|
|
238
|
+
if (!line) continue
|
|
239
|
+
try {
|
|
240
|
+
const obj = JSON.parse(line) as any
|
|
241
|
+
if (obj?.type === 'message' && obj?.message?.role === 'user') {
|
|
242
|
+
const content = obj?.message?.content
|
|
243
|
+
if (typeof content === 'string') return content.slice(0, 80)
|
|
244
|
+
if (Array.isArray(content)) {
|
|
245
|
+
const t = content.find((c: any) => c?.type === 'text' && typeof c?.text === 'string')
|
|
246
|
+
if (t?.text) return String(t.text).slice(0, 80)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// ignore
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Avoid scanning extremely large files fully.
|
|
254
|
+
// If we didn't find a user message in the first ~2000 lines, give up.
|
|
255
|
+
// (Most sessions have it early.)
|
|
256
|
+
if (lines.length > 2000) break
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function listPiSessions(): PiSessionListItem[] {
|
|
266
|
+
const sessionsDir = getPiSessionsDir()
|
|
267
|
+
const files: string[] = []
|
|
268
|
+
walkJsonlFiles(sessionsDir, files)
|
|
269
|
+
|
|
270
|
+
const items: PiSessionListItem[] = []
|
|
271
|
+
|
|
272
|
+
for (const file of files) {
|
|
273
|
+
const first = readFirstLine(file)
|
|
274
|
+
if (!first) continue
|
|
275
|
+
const header = parseSessionHeader(first)
|
|
276
|
+
if (!header) continue
|
|
277
|
+
|
|
278
|
+
let updatedAt: string | null = null
|
|
279
|
+
|
|
280
|
+
let title: string | null = null
|
|
281
|
+
try {
|
|
282
|
+
const tail = readTail(file)
|
|
283
|
+
title = pickTitleFromTail(tail)
|
|
284
|
+
updatedAt = pickUpdatedAtFromTail(tail)
|
|
285
|
+
} catch {
|
|
286
|
+
// ignore
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If the session was named early and grew large, it may fall outside of the tail window.
|
|
290
|
+
if (!title) {
|
|
291
|
+
title = scanSessionInfoNameFromFile(file)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Fallback for updatedAt when we couldn't parse timestamps from tail.
|
|
295
|
+
if (!updatedAt) {
|
|
296
|
+
try {
|
|
297
|
+
updatedAt = statSync(file).mtime.toISOString()
|
|
298
|
+
} catch {
|
|
299
|
+
updatedAt = null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!title) {
|
|
304
|
+
title = pickFallbackTitleFromHead(file)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
items.push({
|
|
308
|
+
sessionId: header.sessionId,
|
|
309
|
+
cwd: header.cwd,
|
|
310
|
+
title,
|
|
311
|
+
updatedAt,
|
|
312
|
+
sessionFile: file
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Sort most recent first.
|
|
317
|
+
items.sort((a, b) => {
|
|
318
|
+
const aa = a.updatedAt ?? ''
|
|
319
|
+
const bb = b.updatedAt ?? ''
|
|
320
|
+
return bb.localeCompare(aa)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
return items
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function findPiSession(sessionId: string): PiSessionListItem | null {
|
|
327
|
+
const all = listPiSessions()
|
|
328
|
+
return all.find(s => s.sessionId === sessionId) ?? null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function findPiSessionFile(sessionId: string): string | null {
|
|
332
|
+
return findPiSession(sessionId)?.sessionFile ?? null
|
|
333
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
function isObject(x: unknown): x is Record<string, unknown> {
|
|
6
|
+
return Boolean(x) && typeof x === 'object' && !Array.isArray(x)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function deepMerge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
|
|
10
|
+
const out: Record<string, unknown> = { ...a }
|
|
11
|
+
for (const [k, v] of Object.entries(b)) {
|
|
12
|
+
const av = out[k]
|
|
13
|
+
if (isObject(av) && isObject(v)) out[k] = deepMerge(av, v)
|
|
14
|
+
else out[k] = v
|
|
15
|
+
}
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readJsonFile(path: string): Record<string, unknown> {
|
|
20
|
+
try {
|
|
21
|
+
if (!existsSync(path)) return {}
|
|
22
|
+
const raw = readFileSync(path, 'utf-8')
|
|
23
|
+
const data = JSON.parse(raw)
|
|
24
|
+
return isObject(data) ? data : {}
|
|
25
|
+
} catch {
|
|
26
|
+
return {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getMergedSettings(cwd: string): Record<string, unknown> {
|
|
31
|
+
const globalSettingsPath = join(getAgentDir(), 'settings.json')
|
|
32
|
+
const projectSettingsPath = resolve(cwd, '.pi', 'settings.json')
|
|
33
|
+
|
|
34
|
+
const global = readJsonFile(globalSettingsPath)
|
|
35
|
+
const project = readJsonFile(projectSettingsPath)
|
|
36
|
+
return deepMerge(global, project)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getAgentDir(): string {
|
|
40
|
+
return process.env.PI_CODING_AGENT_DIR ? resolve(process.env.PI_CODING_AGENT_DIR) : join(homedir(), '.pi', 'agent')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mirror pi settings semantics (global + project merge, project overrides global).
|
|
45
|
+
* Only returns the bits we currently need.
|
|
46
|
+
*/
|
|
47
|
+
export function getEnableSkillCommands(cwd: string): boolean {
|
|
48
|
+
const merged = getMergedSettings(cwd)
|
|
49
|
+
|
|
50
|
+
const direct = merged.enableSkillCommands
|
|
51
|
+
if (typeof direct === 'boolean') return direct
|
|
52
|
+
|
|
53
|
+
// Back-compat: some versions used skills.enableSkillCommands
|
|
54
|
+
const nested = isObject(merged.skills) ? merged.skills.enableSkillCommands : undefined
|
|
55
|
+
if (typeof nested === 'boolean') return nested
|
|
56
|
+
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mirror pi's quietStartup setting: if true, pi suppresses the verbose startup prelude.
|
|
62
|
+
* We use it to decide whether to synthesize + emit our own "startup info" message.
|
|
63
|
+
*/
|
|
64
|
+
export function getQuietStartup(cwd: string): boolean {
|
|
65
|
+
const merged = getMergedSettings(cwd)
|
|
66
|
+
|
|
67
|
+
const direct = merged.quietStartup
|
|
68
|
+
if (typeof direct === 'boolean') return direct
|
|
69
|
+
|
|
70
|
+
// Back-compat: some versions used quietStart
|
|
71
|
+
const legacy = (merged as any).quietStart
|
|
72
|
+
if (typeof legacy === 'boolean') return legacy
|
|
73
|
+
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { getPiAcpSessionMapPath } from './paths.js'
|
|
4
|
+
|
|
5
|
+
export type StoredSession = {
|
|
6
|
+
sessionId: string
|
|
7
|
+
cwd: string
|
|
8
|
+
sessionFile: string
|
|
9
|
+
updatedAt: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SessionMapFile = {
|
|
13
|
+
version: 1
|
|
14
|
+
sessions: Record<string, StoredSession>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureParentDir(path: string) {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadFile(path: string): SessionMapFile {
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(path, 'utf-8')
|
|
24
|
+
const parsed = JSON.parse(raw) as SessionMapFile
|
|
25
|
+
if (parsed?.version !== 1 || typeof parsed.sessions !== 'object' || !parsed.sessions) {
|
|
26
|
+
return { version: 1, sessions: {} }
|
|
27
|
+
}
|
|
28
|
+
return parsed
|
|
29
|
+
} catch {
|
|
30
|
+
return { version: 1, sessions: {} }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function saveFile(path: string, data: SessionMapFile): void {
|
|
35
|
+
ensureParentDir(path)
|
|
36
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SessionStore {
|
|
40
|
+
private readonly path: string
|
|
41
|
+
|
|
42
|
+
constructor(path = getPiAcpSessionMapPath()) {
|
|
43
|
+
this.path = path
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(sessionId: string): StoredSession | null {
|
|
47
|
+
const db = loadFile(this.path)
|
|
48
|
+
return db.sessions[sessionId] ?? null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
upsert(entry: { sessionId: string; cwd: string; sessionFile: string }): void {
|
|
52
|
+
const db = loadFile(this.path)
|
|
53
|
+
db.sessions[entry.sessionId] = {
|
|
54
|
+
sessionId: entry.sessionId,
|
|
55
|
+
cwd: entry.cwd,
|
|
56
|
+
sessionFile: entry.sessionFile,
|
|
57
|
+
updatedAt: new Date().toISOString()
|
|
58
|
+
}
|
|
59
|
+
saveFile(this.path, db)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
delete(sessionId: string): void {
|
|
63
|
+
const db = loadFile(this.path)
|
|
64
|
+
if (!db.sessions[sessionId]) return
|
|
65
|
+
delete db.sessions[sessionId]
|
|
66
|
+
saveFile(this.path, db)
|
|
67
|
+
}
|
|
68
|
+
}
|