tabby-agent-recovery 1.0.0 → 1.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/CHANGELOG.md +13 -0
- package/PRIVACY.md +16 -8
- package/README.md +50 -35
- package/bridge.js +29 -11
- package/native.js +221 -0
- package/package.json +5 -4
- package/screenshot.png +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
- Standalone exact-conversation recovery for Codex and Claude Code. Linux detects running agents; explicit UUID resume profiles work without process discovery.
|
|
6
|
+
- Native and external bound panes stay open after process exit and initialize in background tabs.
|
|
7
|
+
- Missing external launchers no longer replace existing reconnect scripts with a broken command.
|
|
8
|
+
- Extra snapshots omit scrollback and nonessential environment by default; retain the last 20 startup snapshots.
|
|
9
|
+
- Document the difference between resuming conversation history and keeping a process alive, platform support, and explicit-profile fallback.
|
|
10
|
+
|
|
11
|
+
## 1.0.0
|
|
12
|
+
|
|
13
|
+
- Initial Tabby recovery bridge for existing agent-sessions bindings, split layouts, and optional terminal logs.
|
package/PRIVACY.md
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
# Privacy
|
|
2
2
|
|
|
3
|
-
`tabby-agent-recovery`
|
|
3
|
+
`tabby-agent-recovery` makes no network requests and has no analytics endpoint. Agent CLIs and Tabby have their own network behavior.
|
|
4
|
+
|
|
5
|
+
## Read locally
|
|
6
|
+
|
|
7
|
+
On Linux, native detection reads the current user's process ancestry and, for candidate agent processes in a pane, command lines, working directories, executable links, and environment. Only `CODEX_HOME` and `CLAUDE_CONFIG_DIR` are carried into the recovered command; other process environment values are discarded. The plugin reads the first metadata record of an open transcript or Claude's local process registry to identify the conversation. It does not search unrelated conversations by recency.
|
|
8
|
+
|
|
9
|
+
If a compatible `agent-sessions` launcher is installed, its `pane-bindings` command and local state provide the optional external bindings.
|
|
4
10
|
|
|
5
11
|
## Written locally
|
|
6
12
|
|
|
7
|
-
Under
|
|
13
|
+
Under `$AGENT_SESSIONS_STATE`, or `~/.local/state/agent-sessions` when its state file exists, otherwise `~/.local/state/tabby-agent-recovery`:
|
|
8
14
|
|
|
9
|
-
- `tabby-layout.json
|
|
10
|
-
- `tabby-latest-snapshot.json
|
|
11
|
-
- `
|
|
15
|
+
- `tabby-layout.json`: external pane keys and positions.
|
|
16
|
+
- `tabby-latest-snapshot.json`: the latest saved recovery layout.
|
|
17
|
+
- `layout-snapshots/`: up to 20 startup snapshots.
|
|
18
|
+
- `tabby-layout-before-binding.json`: the first layout backup before external binding migration.
|
|
19
|
+
- `terminal-logs/`: terminal output, only with `TABBY_AGENT_RECOVERY_LOG=1`.
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
By default the extra layout snapshots omit scrollback and profile environment except `CODEX_HOME` / `CLAUDE_CONFIG_DIR`. They still contain conversation IDs, titles, paths, and commands, which can be sensitive. `TABBY_AGENT_RECOVERY_SCROLLBACK=1` opts into full recovery tokens, including terminal scrollback and profile environment. Output logs may contain secrets printed by programs and are not automatically pruned.
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
Files are created with mode `0600` and directories with `0700` where supported by the operating system. Tabby's own local recovery storage remains separate: the plugin updates its resume commands but does not remove Tabby's saved scrollback or existing profile environment.
|
|
16
24
|
|
|
17
|
-
Keyboard input is not
|
|
25
|
+
Keyboard input is not logged by the plugin. No credentials are uploaded. Resumed agents use their existing local authentication.
|
package/README.md
CHANGED
|
@@ -1,66 +1,81 @@
|
|
|
1
1
|
# tabby-agent-recovery
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Tabby already saves the main window layout. After a reboot the old PTY is gone, so a pane that used to hold a live session comes back as a new shell — or disappears if the process exited with `behaviorOnSessionEnd: auto`. This plugin rewrites the recovery token to a stable attach command, keeps the split tree, and starts bound panes without stealing focus.
|
|
6
|
-
|
|
7
|
-

|
|
3
|
+
Resume the same Codex or Claude Code conversation in the same Tabby pane after a restart. Keep nested splits and finished panes. No separate launcher is required.
|
|
8
4
|
|
|
9
5
|
## Install
|
|
10
6
|
|
|
11
|
-
Tabby → **Settings
|
|
7
|
+
Tabby → **Settings → Plugins** → search **agent-recovery** → Install → restart Tabby.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
If the search has not indexed the package yet, open **Plugins folder** in those settings and run:
|
|
14
10
|
|
|
15
11
|
```bash
|
|
16
|
-
npm install tabby-agent-recovery
|
|
17
|
-
# until the npm listing is live:
|
|
18
|
-
# npm install github:vixkosla/tabby-agent-recovery
|
|
12
|
+
npm install tabby-agent-recovery@latest
|
|
19
13
|
```
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
Restart Tabby after installing or updating. Enable Tabby's **Restore tabs on start** option. The agent CLI must already be installed and authenticated.
|
|
16
|
+
|
|
17
|
+
If you use a development copy through a symlink in the plugins directory, update that source copy instead: npm installation would replace the symlink.
|
|
18
|
+
|
|
19
|
+
## Standalone recovery
|
|
20
|
+
|
|
21
|
+
On Linux, the plugin inspects the processes belonging to each local pane when Tabby saves its tabs. For Codex and Claude Code, it records the **exact conversation UUID**, executable, working directory, and supported CLI options. After a restart, the pane runs `codex resume <UUID>` or `claude --resume <UUID>`.
|
|
22
|
+
|
|
23
|
+
- New conversations are identified from the pane's open transcript or Claude's process registry. Explicit resume commands also work.
|
|
24
|
+
- Nested splits, their order, titles, and ratios are kept by Tabby's existing recovery system.
|
|
25
|
+
- Identified panes stay in the layout when their process finishes.
|
|
26
|
+
- Bound background panes start without selecting the tab or focusing the window.
|
|
27
|
+
- The plugin keeps the latest layout checkpoint and the last 20 startup snapshots.
|
|
28
|
+
|
|
29
|
+
This starts a **new agent process with the saved conversation**. It does not keep computation alive while the computer is off or submit a new prompt. CLI login, conversation files, and the executable must still be available. After a crash, only Tabby's most recently saved layout can be recovered.
|
|
30
|
+
|
|
31
|
+
### Supported cases and fallback
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
Automatic process discovery currently supports **Linux**, Codex, and Claude Code. It does not guess by folder, modification time, `--last`, or `--continue`. Headless jobs, ambiguous panes, subagent transcripts, unknown CLI flags, and shared daemons without an identifiable pane transcript are left unchanged.
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
- On recover, migrate the saved layout before Tabby builds tabs. Nested splits, titles, and ratios stay as they were.
|
|
27
|
-
- Bound panes use `behaviorOnSessionEnd: keep`, so a crash does not delete the tab from the layout.
|
|
28
|
-
- Bound background panes start their PTY without selecting the tab or focusing the window.
|
|
29
|
-
- A private layout snapshot is written once at launch, before autosave can replace it.
|
|
35
|
+
For those cases, or on **macOS and Windows**, create a local Tabby profile with an explicit resume command:
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
| Profile field | Codex | Claude Code |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| Command | Full path to `codex` | Full path to `claude` |
|
|
40
|
+
| Arguments | `resume <UUID>` | `--resume <UUID>` |
|
|
41
|
+
| Working directory | Your project directory | Your project directory |
|
|
32
42
|
|
|
33
|
-
|
|
43
|
+
Replace `<UUID>` with the actual conversation ID. The arguments are separate from the executable path. On Codex versions that support it, `--no-daemon` can make a new conversation's transcript identifiable inside its pane; an explicit UUID is the reliable fallback for an existing conversation.
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
Recognized options include model, profile, sandbox/approval or permission mode, additional directories, and selected UI flags. Already selected permission bypass flags are preserved, never enabled by the plugin. Unknown or sensitive inline configuration makes automatic recovery skip that invocation. Ordinary shells and arbitrary commands are not converted into agent sessions.
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
## Check it
|
|
48
|
+
|
|
49
|
+
1. Start a conversation with Codex or Claude Code inside a Tabby pane.
|
|
50
|
+
2. Create a split and close Tabby normally so it saves the layout.
|
|
51
|
+
3. Open Tabby again. Check the conversation identity, working directory, and split arrangement.
|
|
52
|
+
4. If it opens an ordinary shell, use an explicit resume profile as described above.
|
|
53
|
+
|
|
54
|
+
## Optional existing agent-sessions integration
|
|
55
|
+
|
|
56
|
+
If you already run a compatible local `agent-sessions` launcher, the plugin can preserve `agent-sessions attach <key>` bindings and migrate saved PTY bindings before Tabby builds its tabs. The launcher is optional and is not installed by this package. Existing reconnect scripts are not replaced when the launcher is missing.
|
|
57
|
+
|
|
58
|
+
| Environment variable | Default / effect |
|
|
38
59
|
| --- | --- |
|
|
39
60
|
| `AGENT_SESSIONS_LAUNCHER` | `~/.local/bin/agent-sessions` |
|
|
40
|
-
| `AGENT_SESSIONS_STATE` | `~/.local/state/agent-sessions` if `state.json` exists
|
|
41
|
-
| `
|
|
61
|
+
| `AGENT_SESSIONS_STATE` | `~/.local/state/agent-sessions` if its `state.json` exists; otherwise `~/.local/state/tabby-agent-recovery` |
|
|
62
|
+
| `TABBY_AGENT_RECOVERY_SCROLLBACK=1` | Include terminal scrollback and full profile environment in extra snapshots; off by default |
|
|
63
|
+
| `TABBY_AGENT_RECOVERY_LOG=1` | Write terminal output to `<state>/terminal-logs/`; off by default |
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
Set these in the environment that launches Tabby. No configuration is needed for ordinary standalone use.
|
|
44
66
|
|
|
45
|
-
##
|
|
67
|
+
## Privacy
|
|
46
68
|
|
|
47
|
-
|
|
69
|
+
No network requests or analytics are made by the plugin. Default extra snapshots omit terminal scrollback and environment variables other than `CODEX_HOME` / `CLAUDE_CONFIG_DIR`. Paths and commands are still saved and may be sensitive. Tabby's own recovery storage is separate and retains its existing behavior. See [PRIVACY.md](PRIVACY.md).
|
|
48
70
|
|
|
49
71
|
## Development
|
|
50
72
|
|
|
51
73
|
```bash
|
|
52
74
|
npm test
|
|
53
|
-
TABBY_PLUGINS="$
|
|
75
|
+
TABBY_PLUGINS="$PWD" tabby --debug
|
|
54
76
|
```
|
|
55
77
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Tabby's plugin catalog is the public npm registry. There is no separate review form: a package named `tabby-*` with keyword `tabby-plugin` appears in Settings → Plugins after `npm publish`.
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
npm login --auth-type=web
|
|
62
|
-
npm publish --access public
|
|
63
|
-
```
|
|
78
|
+
Tests cover native identity discovery, ambiguity, CLI wrappers, missing launchers, nested layouts, background initialization, privacy defaults, and snapshot retention. Integration was checked in an isolated Tabby 1.0.238 on Linux, using local CLI fixtures without network/model calls: two conversations resumed with their exact IDs after a full app restart, splits retained their ratios, and an exited pane stayed open. Native metadata formats can change between agent versions; explicit resume profiles remain the fallback.
|
|
64
79
|
|
|
65
80
|
## License
|
|
66
81
|
|
package/bridge.js
CHANGED
|
@@ -8,6 +8,11 @@ const { execFile } = require('child_process')
|
|
|
8
8
|
const { promisify } = require('util')
|
|
9
9
|
const run = promisify(execFile)
|
|
10
10
|
const KEY = /^[a-z0-9-]{1,80}$/
|
|
11
|
+
const native = require('./native')
|
|
12
|
+
|
|
13
|
+
function executable (file) {
|
|
14
|
+
try { fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return true } catch { return false }
|
|
15
|
+
}
|
|
11
16
|
|
|
12
17
|
function readJSON (file, fallback) {
|
|
13
18
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
|
|
@@ -64,7 +69,7 @@ function tokenKey (token, statePath, launcher) {
|
|
|
64
69
|
|
|
65
70
|
function bindToken (token, key, state, launcher) {
|
|
66
71
|
key = canonical(key, state)
|
|
67
|
-
if (!key || state.entries[key].enabled === false) return token
|
|
72
|
+
if (!key || state.entries[key].enabled === false || !executable(launcher)) return token
|
|
68
73
|
token.profile = { ...token.profile, behaviorOnSessionEnd: 'keep', options: {
|
|
69
74
|
...token.profile.options,
|
|
70
75
|
command: launcher,
|
|
@@ -102,15 +107,16 @@ function manifest (tokens, statePath, launcher) {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function layoutOnly (tokens) {
|
|
105
|
-
return tokens.map(({ savedState, children, ...token }) => ({
|
|
106
|
-
...token,
|
|
110
|
+
return tokens.map(({ savedState, children, profile, ...token }) => ({
|
|
111
|
+
...token, ...(profile ? { profile: { ...profile, options: { ...profile.options, env: native.safeEnvironment(profile.options?.env) } } } : {}),
|
|
107
112
|
...(Array.isArray(children) ? { children: layoutOnly(children) } : {}),
|
|
108
113
|
}))
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
function install ({
|
|
112
117
|
core, local, ipc, storage, statePath, launcher, lookupBindings,
|
|
113
|
-
logTerminalOutput,
|
|
118
|
+
logTerminalOutput, includeScrollback = process.env.TABBY_AGENT_RECOVERY_SCROLLBACK === '1',
|
|
119
|
+
detectNative = native.createDetector(), warning = console.warn,
|
|
114
120
|
} = {}) {
|
|
115
121
|
statePath ||= defaultStatePath()
|
|
116
122
|
launcher ||= defaultLauncher()
|
|
@@ -121,6 +127,7 @@ function install ({
|
|
|
121
127
|
const state = () => readJSON(path.join(statePath, 'state.json'), { entries: {} })
|
|
122
128
|
let cache, cachedAt = 0
|
|
123
129
|
const bindings = () => {
|
|
130
|
+
if (!lookupBindings && !executable(launcher)) return Promise.resolve({})
|
|
124
131
|
if (!cache || Date.now() - cachedAt > 1000) {
|
|
125
132
|
cachedAt = Date.now()
|
|
126
133
|
cache = (lookupBindings ? lookupBindings() : run(launcher, ['pane-bindings'], { timeout: 2500, maxBuffer: 65536 })
|
|
@@ -138,7 +145,15 @@ function install ({
|
|
|
138
145
|
const pty = token.profile?.options?.restoreFromPTYID
|
|
139
146
|
const pid = pty && ipc.sendSync('pty:get-pid', pty)
|
|
140
147
|
const key = (pid && (await bindings())[String(pid)]) || tokenKey(token, statePath, launcher)
|
|
141
|
-
if (key) bindToken(token, key, state(), launcher)
|
|
148
|
+
if (key && canonical(key, state()) && executable(launcher)) bindToken(token, key, state(), launcher)
|
|
149
|
+
else native.bindNative(token, (pid && await detectNative(Number(pid))) || native.fromProfile(token))
|
|
150
|
+
if (token.profile?.behaviorOnSessionEnd === 'keep' &&
|
|
151
|
+
(native.fromProfile(token) || tokenKey(token, statePath, launcher))) {
|
|
152
|
+
// Keep the newly identified pane now, not only after its next restart.
|
|
153
|
+
const { restoreFromPTYID, ...options } = token.profile.options
|
|
154
|
+
tab.profile = { ...token.profile, options }
|
|
155
|
+
if (tab.session) tab.session.pauseAfterExit = true
|
|
156
|
+
}
|
|
142
157
|
} catch {
|
|
143
158
|
warning('agent-recovery: could not refresh a pane binding; preserving its profile')
|
|
144
159
|
}
|
|
@@ -156,15 +171,17 @@ function install ({
|
|
|
156
171
|
if (!launchSnapshotSaved && Array.isArray(original) && original.length) {
|
|
157
172
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
158
173
|
atomicJSON(path.join(statePath, 'layout-snapshots', `${stamp}-${process.pid}.json`), {
|
|
159
|
-
version: 1, saved_at: new Date().toISOString(), tabs: original,
|
|
174
|
+
version: 1, saved_at: new Date().toISOString(), tabs: includeScrollback ? original : layoutOnly(original),
|
|
160
175
|
})
|
|
176
|
+
const snapshots = path.join(statePath, 'layout-snapshots')
|
|
177
|
+
fs.readdirSync(snapshots).filter(name => /^\d{4}-.*\.json$/.test(name)).sort().slice(0, -20).forEach(name => fs.unlinkSync(path.join(snapshots, name)))
|
|
161
178
|
launchSnapshotSaved = true
|
|
162
179
|
}
|
|
163
180
|
const tokens = migrate(original, state(), pending, statePath, launcher)
|
|
164
181
|
const after = JSON.stringify(tokens)
|
|
165
182
|
if (before !== after) {
|
|
166
183
|
const backup = path.join(statePath, 'tabby-layout-before-binding.json')
|
|
167
|
-
if (!fs.existsSync(backup)) atomicJSON(backup, JSON.parse(before))
|
|
184
|
+
if (!fs.existsSync(backup)) atomicJSON(backup, includeScrollback ? JSON.parse(before) : layoutOnly(JSON.parse(before)))
|
|
168
185
|
storage.tabsRecovery = after
|
|
169
186
|
}
|
|
170
187
|
atomicJSON(path.join(statePath, 'tabby-layout.json'), manifest(tokens, statePath, launcher))
|
|
@@ -183,7 +200,7 @@ function install ({
|
|
|
183
200
|
const tokens = JSON.parse(storage.tabsRecovery)
|
|
184
201
|
atomicJSON(path.join(statePath, 'tabby-layout.json'), manifest(tokens, statePath, launcher))
|
|
185
202
|
atomicJSON(path.join(statePath, 'tabby-latest-snapshot.json'), {
|
|
186
|
-
version: 1, saved_at: new Date().toISOString(), tabs: tokens,
|
|
203
|
+
version: 1, saved_at: new Date().toISOString(), tabs: includeScrollback ? tokens : layoutOnly(tokens),
|
|
187
204
|
})
|
|
188
205
|
} catch {
|
|
189
206
|
warning('agent-recovery: could not checkpoint pane manifest')
|
|
@@ -221,12 +238,13 @@ function install ({
|
|
|
221
238
|
terminal.ngOnInit = function (...args) {
|
|
222
239
|
const token = { type: 'app:local-tab', profile: this.profile }
|
|
223
240
|
const key = tokenKey(token, statePath, launcher)
|
|
224
|
-
|
|
241
|
+
const bound = (key && state().entries[key]?.enabled !== false) || native.fromProfile(token)
|
|
242
|
+
if (bound) {
|
|
225
243
|
this.profile.behaviorOnSessionEnd = 'keep'
|
|
226
244
|
this.profile.options.pauseAfterExit = true
|
|
227
245
|
}
|
|
228
246
|
onInit.apply(this, args)
|
|
229
|
-
if (!
|
|
247
|
+
if (!bound || this.session) return
|
|
230
248
|
const initialize = this.initializeSession
|
|
231
249
|
initialize.call(this, this.size?.columns || 80, this.size?.rows || 24)
|
|
232
250
|
const started = this.session
|
|
@@ -243,5 +261,5 @@ function install ({
|
|
|
243
261
|
|
|
244
262
|
module.exports = {
|
|
245
263
|
install, canonical, tokenKey, bindToken, walk, migrate, manifest, layoutOnly,
|
|
246
|
-
defaultLauncher, defaultStatePath,
|
|
264
|
+
defaultLauncher, defaultStatePath, executable,
|
|
247
265
|
}
|
package/native.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Native conversation recovery: no supervisor, shell eval or "latest" lookup.
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
const ENV_KEYS = ['CODEX_HOME', 'CLAUDE_CONFIG_DIR']
|
|
9
|
+
const FLAGS = {
|
|
10
|
+
codex: {
|
|
11
|
+
value: ['--model', '-m', '--profile', '-p', '--sandbox', '-s', '--ask-for-approval', '-a', '--add-dir', '--enable', '--disable', '--local-provider'],
|
|
12
|
+
switch: ['--search', '--no-alt-screen', '--no-daemon', '--oss', '--strict-config', '--full-auto', '--dangerously-bypass-approvals-and-sandbox', '--yolo'],
|
|
13
|
+
},
|
|
14
|
+
claude: {
|
|
15
|
+
value: ['--model', '--effort', '--permission-mode', '--agent', '--add-dir'],
|
|
16
|
+
switch: ['--chrome', '--no-chrome', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions'],
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function invocation (argv) {
|
|
21
|
+
if (!Array.isArray(argv) || !argv.length) return null
|
|
22
|
+
let command = argv[0], prefix = [], name = path.basename(command).replace(/\.exe$/i, '')
|
|
23
|
+
if (/^node(?:js)?$/.test(name) && typeof argv[1] === 'string') {
|
|
24
|
+
if (/@anthropic-ai[\\/]claude-code[\\/]cli\.js$/.test(argv[1])) name = 'claude'
|
|
25
|
+
else if (/@openai[\\/]codex[\\/]bin[\\/]codex\.js$/.test(argv[1])) name = 'codex'
|
|
26
|
+
else return null
|
|
27
|
+
prefix = [argv[1]]
|
|
28
|
+
}
|
|
29
|
+
if (!FLAGS[name]) return null
|
|
30
|
+
return { kind: name, command, prefix, args: argv.slice(1 + prefix.length) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resumeArgs (kind, args, id) {
|
|
34
|
+
if (!UUID.test(id || '') || !FLAGS[kind]) return null
|
|
35
|
+
const kept = [], rules = FLAGS[kind]
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const arg = args[i]
|
|
38
|
+
if (typeof arg !== 'string') return null
|
|
39
|
+
if (['exec', 'review', 'app-server', 'mcp', 'login', 'logout', 'auth', 'update', 'install', 'agents', 'attach', 'remote-control', '--help', '-h', '--version', '-V', '--print'].includes(arg) || (kind === 'claude' && arg === '-p')) return null
|
|
40
|
+
if (kind === 'codex' && arg === 'resume') {
|
|
41
|
+
if (args[i + 1] && !args[i + 1].startsWith('-')) i++
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (kind === 'claude' && ['--resume', '-r', '--session-id'].includes(arg)) {
|
|
45
|
+
if (args[i + 1] && !args[i + 1].startsWith('-')) i++
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (['--last', '--all'].includes(arg) || (kind === 'claude' && ['--continue', '-c'].includes(arg))) continue
|
|
49
|
+
const equal = arg.indexOf('=')
|
|
50
|
+
const flag = equal > 0 ? arg.slice(0, equal) : arg
|
|
51
|
+
if (rules.value.includes(flag)) {
|
|
52
|
+
const value = equal > 0 ? arg.slice(equal + 1) : args[++i]
|
|
53
|
+
if (!value || value.startsWith('-')) return null
|
|
54
|
+
kept.push(flag, value)
|
|
55
|
+
} else if (rules.switch.includes(arg)) kept.push(arg)
|
|
56
|
+
else if (kind === 'codex' && ['-c', '--config'].includes(flag)) {
|
|
57
|
+
const value = equal > 0 ? arg.slice(equal + 1) : args[++i]
|
|
58
|
+
// Preserve ordinary UI configuration, not inline credentials/hooks.
|
|
59
|
+
if (!/^(check_for_update_on_startup|model_reasoning_effort)=(?:true|false|"(?:low|medium|high|xhigh|max)")$/.test(value || '')) return null
|
|
60
|
+
kept.push(flag, value)
|
|
61
|
+
} else if (arg.startsWith('-')) return null
|
|
62
|
+
// Positional initial prompts already belong to the native conversation.
|
|
63
|
+
}
|
|
64
|
+
return [...kept, ...(kind === 'codex' ? ['resume', id] : ['--resume', id])]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function explicitID ({ kind, args }) {
|
|
68
|
+
const ids = []
|
|
69
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
70
|
+
if ((kind === 'codex' && args[i] === 'resume') || (kind === 'claude' && ['--resume', '-r', '--session-id'].includes(args[i]))) {
|
|
71
|
+
if (UUID.test(args[i + 1])) ids.push(args[i + 1])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new Set(ids).size === 1 ? ids[0] : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeEnvironment (env = {}) {
|
|
78
|
+
return Object.fromEntries(ENV_KEYS.filter(key => typeof env[key] === 'string').map(key => [key, env[key]]))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fromProfile (token) {
|
|
82
|
+
const options = token.profile?.options || {}
|
|
83
|
+
const call = invocation([options.command || '', ...(options.args || [])])
|
|
84
|
+
if (!call) return null
|
|
85
|
+
const id = explicitID(call), args = resumeArgs(call.kind, call.args, id)
|
|
86
|
+
return args ? { kind: call.kind, id, command: call.command, args: [...call.prefix, ...args], cwd: options.cwd, env: safeEnvironment(options.env) } : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function firstJSON (file, whole = false) {
|
|
90
|
+
let handle
|
|
91
|
+
try {
|
|
92
|
+
handle = await fs.promises.open(file, 'r')
|
|
93
|
+
const buffer = Buffer.alloc(65536)
|
|
94
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
95
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8')
|
|
96
|
+
return JSON.parse(whole ? text : text.split('\n')[0])
|
|
97
|
+
} catch { return null } finally { await handle?.close() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createDetector ({ procRoot = '/proc', platform = process.platform, home = os.homedir(), uid = process.getuid?.() } = {}) {
|
|
101
|
+
let inventory, inventoryAt = 0
|
|
102
|
+
const processTree = async () => {
|
|
103
|
+
if (inventory && Date.now() - inventoryAt < 1000) return inventory
|
|
104
|
+
inventoryAt = Date.now()
|
|
105
|
+
inventory = (async () => {
|
|
106
|
+
const rows = new Map()
|
|
107
|
+
for (const name of await fs.promises.readdir(procRoot)) {
|
|
108
|
+
if (!/^\d+$/.test(name)) continue
|
|
109
|
+
try {
|
|
110
|
+
const dir = path.join(procRoot, name)
|
|
111
|
+
if (uid != null && (await fs.promises.stat(dir)).uid !== uid) continue
|
|
112
|
+
const stat = (await fs.promises.readFile(path.join(dir, 'stat'), 'utf8')).split(') ').slice(1).join(') ').split(' ')
|
|
113
|
+
rows.set(Number(name), { pid: Number(name), ppid: Number(stat[1]), start: stat[19], dir })
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
return rows
|
|
117
|
+
})()
|
|
118
|
+
return inventory
|
|
119
|
+
}
|
|
120
|
+
async function readProcess (info) {
|
|
121
|
+
try {
|
|
122
|
+
const argv = (await fs.promises.readFile(path.join(info.dir, 'cmdline'), 'utf8')).split('\0').filter(Boolean)
|
|
123
|
+
const call = invocation(argv)
|
|
124
|
+
if (!call) return null
|
|
125
|
+
const processEnv = Object.fromEntries((await fs.promises.readFile(path.join(info.dir, 'environ'), 'utf8')).split('\0').filter(x => x.includes('=')).map(x => [x.slice(0, x.indexOf('=')), x.slice(x.indexOf('=') + 1)]))
|
|
126
|
+
const env = safeEnvironment(processEnv)
|
|
127
|
+
const cwd = await fs.promises.readlink(path.join(info.dir, 'cwd'))
|
|
128
|
+
if (!path.isAbsolute(call.command)) {
|
|
129
|
+
const choices = call.command.includes('/') ? [path.resolve(cwd, call.command)] : (processEnv.PATH || '').split(path.delimiter).filter(path.isAbsolute).map(dir => path.join(dir, call.command))
|
|
130
|
+
const resolved = choices.find(file => {
|
|
131
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true } catch { return false }
|
|
132
|
+
})
|
|
133
|
+
call.command = resolved || await fs.promises.readlink(path.join(info.dir, 'exe'))
|
|
134
|
+
}
|
|
135
|
+
return { ...info, ...call, env, cwd }
|
|
136
|
+
} catch { return null }
|
|
137
|
+
}
|
|
138
|
+
async function identity (proc, rows) {
|
|
139
|
+
if (proc.kind === 'claude') {
|
|
140
|
+
const root = proc.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
|
|
141
|
+
const meta = await firstJSON(path.join(root, 'sessions', `${proc.pid}.json`), true)
|
|
142
|
+
if (meta?.kind === 'interactive' && String(meta.procStart) === proc.start && UUID.test(meta.sessionId)) return meta.sessionId
|
|
143
|
+
}
|
|
144
|
+
// Only files held by this pane's process (or its own private app-server).
|
|
145
|
+
// Never inspect a shared daemon or pick a conversation by modification time.
|
|
146
|
+
const owners = [proc, ...[...rows.values()].filter(row => row.ppid === proc.pid)]
|
|
147
|
+
const ids = new Set()
|
|
148
|
+
for (const owner of owners) {
|
|
149
|
+
if (owner.pid !== proc.pid) {
|
|
150
|
+
const child = await readProcess(owner)
|
|
151
|
+
if (!child || child.kind !== proc.kind || !child.args.includes('app-server')) continue
|
|
152
|
+
}
|
|
153
|
+
let fds
|
|
154
|
+
try { fds = await fs.promises.readdir(path.join(owner.dir, 'fd')) } catch { continue }
|
|
155
|
+
for (const fd of fds) {
|
|
156
|
+
try {
|
|
157
|
+
const file = await fs.promises.readlink(path.join(owner.dir, 'fd', fd))
|
|
158
|
+
if (!file.endsWith('.jsonl')) continue
|
|
159
|
+
if (proc.kind === 'codex' && file.includes('/sessions/')) {
|
|
160
|
+
const meta = await firstJSON(file), p = meta?.payload
|
|
161
|
+
if (meta?.type !== 'session_meta' || !UUID.test(p?.id)) continue
|
|
162
|
+
if (p.source && typeof p.source === 'object' && 'subagent' in p.source) continue
|
|
163
|
+
if (p.session_id && p.session_id !== p.id) continue
|
|
164
|
+
ids.add(p.id)
|
|
165
|
+
} else if (proc.kind === 'claude' && file.includes('/projects/')) {
|
|
166
|
+
const id = path.basename(file, '.jsonl'), meta = await firstJSON(file)
|
|
167
|
+
if (UUID.test(id) && meta?.sessionId === id) ids.add(id)
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (ids.size > 1) return null
|
|
173
|
+
return [...ids][0] || explicitID(proc)
|
|
174
|
+
}
|
|
175
|
+
return async function detect (ptyPID) {
|
|
176
|
+
if (platform !== 'linux' || !Number.isInteger(ptyPID) || ptyPID <= 0) return null
|
|
177
|
+
try {
|
|
178
|
+
const rows = await processTree(), descendants = new Set([ptyPID])
|
|
179
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
180
|
+
let added = false
|
|
181
|
+
for (const row of rows.values()) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { descendants.add(row.pid); added = true }
|
|
182
|
+
if (!added) break
|
|
183
|
+
}
|
|
184
|
+
if (descendants.size > 128) return null
|
|
185
|
+
const candidates = []
|
|
186
|
+
for (const pid of descendants) {
|
|
187
|
+
const proc = rows.has(pid) && await readProcess(rows.get(pid))
|
|
188
|
+
if (proc && resumeArgs(proc.kind, proc.args, '00000000-0000-4000-8000-000000000001')) candidates.push(proc)
|
|
189
|
+
}
|
|
190
|
+
// The npm Codex entrypoint stays alive above its native binary.
|
|
191
|
+
// Treat that wrapper and binary as one interactive CLI.
|
|
192
|
+
const interactive = candidates.filter(proc => !proc.prefix.length || !candidates.some(child => child.ppid === proc.pid && child.kind === proc.kind && !child.prefix.length))
|
|
193
|
+
const top = interactive.filter(proc => {
|
|
194
|
+
let ppid = proc.ppid
|
|
195
|
+
for (let depth = 0; depth < 32 && rows.has(ppid); depth++) {
|
|
196
|
+
if (interactive.some(other => other.pid === ppid)) return false
|
|
197
|
+
ppid = rows.get(ppid).ppid
|
|
198
|
+
}
|
|
199
|
+
return true
|
|
200
|
+
})
|
|
201
|
+
if (top.length !== 1) return null
|
|
202
|
+
const proc = top[0], id = await identity(proc, rows)
|
|
203
|
+
const args = resumeArgs(proc.kind, proc.args, id)
|
|
204
|
+
if (!args) return null
|
|
205
|
+
return { kind: proc.kind, id, command: proc.command, args: [...proc.prefix, ...args], cwd: proc.cwd, env: proc.env }
|
|
206
|
+
} catch { return null }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bindNative (token, entry) {
|
|
211
|
+
if (!entry || !UUID.test(entry.id || '') || !Array.isArray(entry.args)) return token
|
|
212
|
+
token.profile = { ...token.profile, behaviorOnSessionEnd: 'keep', options: {
|
|
213
|
+
...token.profile.options, command: entry.command, args: entry.args,
|
|
214
|
+
cwd: entry.cwd || token.profile.options?.cwd,
|
|
215
|
+
env: { ...token.profile.options?.env, ...safeEnvironment(entry.env) }, pauseAfterExit: true,
|
|
216
|
+
} }
|
|
217
|
+
delete token.profile.options.agentSessionKey
|
|
218
|
+
return token
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { invocation, resumeArgs, explicitID, fromProfile, createDetector, bindNative, safeEnvironment }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabby-agent-recovery",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Resume exact Codex and Claude Code conversations in Tabby after restart. Keep splits and finished panes; no separate launcher required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tabby-plugin",
|
|
7
7
|
"tabby",
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"index.js",
|
|
15
15
|
"bridge.js",
|
|
16
|
+
"native.js",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE",
|
|
18
19
|
"PRIVACY.md",
|
|
19
|
-
"
|
|
20
|
+
"CHANGELOG.md"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
|
-
"test": "node --test test
|
|
23
|
+
"test": "node --test test/*.test.cjs",
|
|
23
24
|
"prepublishOnly": "npm test"
|
|
24
25
|
},
|
|
25
26
|
"repository": {
|
package/screenshot.png
DELETED
|
Binary file
|