@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.
Files changed (134) hide show
  1. package/CREDITS.md +23 -0
  2. package/LICENSE +22 -0
  3. package/README.md +110 -0
  4. package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
  5. package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
  6. package/extensions/acp/cmd/pig-acp/go.mod +6 -0
  7. package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
  8. package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
  9. package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
  10. package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
  11. package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
  12. package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
  13. package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
  14. package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
  15. package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
  16. package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
  17. package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
  18. package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
  19. package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
  20. package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
  21. package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
  22. package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
  23. package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
  24. package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
  25. package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
  26. package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
  27. package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
  28. package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
  29. package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
  30. package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
  31. package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
  32. package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
  33. package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
  34. package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
  35. package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
  36. package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
  37. package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
  38. package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
  39. package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
  40. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
  41. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
  42. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
  43. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
  44. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
  45. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
  46. package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
  47. package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
  48. package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
  49. package/extensions/acp/cmd/pig-acp/main.go +177 -0
  50. package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
  51. package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
  52. package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
  53. package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
  54. package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
  55. package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
  56. package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
  57. package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
  58. package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
  59. package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
  60. package/extensions/acp/extension.go +63 -0
  61. package/extensions/acp/extension_test.go +111 -0
  62. package/extensions/acp/fakehost_test.go +548 -0
  63. package/extensions/acp/go.mod +6 -0
  64. package/extensions/acp/go.sum +2 -0
  65. package/package.json +41 -0
  66. package/port/PORT.md +147 -0
  67. package/port/interop/package-lock.json +89 -0
  68. package/port/interop/package.json +13 -0
  69. package/port/interop/run.mjs +229 -0
  70. package/port/mutate.mjs +110 -0
  71. package/port/mutation-results.txt +107 -0
  72. package/port/mutations.json +748 -0
  73. package/port/oracle/pi-acp/LICENSE +21 -0
  74. package/port/oracle/pi-acp/README.md +209 -0
  75. package/port/oracle/pi-acp/package.json +65 -0
  76. package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
  77. package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
  78. package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
  79. package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
  80. package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
  81. package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
  82. package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
  83. package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
  84. package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
  85. package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
  86. package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
  87. package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
  88. package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
  89. package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
  90. package/port/oracle/pi-acp/src/index.ts +82 -0
  91. package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
  92. package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
  93. package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
  94. package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
  95. package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
  96. package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
  97. package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
  98. package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
  99. package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
  100. package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
  101. package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
  102. package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
  103. package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
  104. package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
  105. package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
  106. package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
  107. package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
  108. package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
  109. package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
  110. package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
  111. package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
  112. package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
  113. package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
  114. package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
  115. package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
  116. package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
  117. package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
  118. package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
  119. package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
  120. package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
  121. package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
  122. package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
  123. package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
  124. package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
  125. package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
  126. package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
  127. package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
  128. package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
  129. package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
  130. package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
  131. package/port/schema/LICENSE +191 -0
  132. package/port/schema/schema.json +8015 -0
  133. package/port/upstream-tests.json +201 -0
  134. 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
+ }