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 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` does not send network requests. It only reads and writes files on the local machine.
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 the state directory (`$AGENT_SESSIONS_STATE`, or `~/.local/state/agent-sessions` when that tree already exists, otherwise `~/.local/state/tabby-agent-recovery`):
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` — pane keys and split paths
10
- - `tabby-latest-snapshot.json` and `layout-snapshots/` — recovery tokens, including cwd, command, and Tabby's saved scrollback
11
- - `terminal-logs/` — pane output, only when `TABBY_AGENT_RECOVERY_LOG=1`
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
- Files are created mode `0600` in a `0700` directory.
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
- ## Not collected
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 recorded by this plugin. Credentials, cookies, and npm/GitHub tokens are not read. There is no analytics endpoint.
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
- Restore Tabby panes after a restart.
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
- ![Recovered pane](screenshot.png)
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** → **Plugins** → search **agent-recovery** → Install → restart Tabby.
7
+ Tabby → **Settings → Plugins** → search **agent-recovery** → Install → restart Tabby.
12
8
 
13
- Or from the plugins directory Tabby shows under Settings → Plugins:
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
- Then restart Tabby. The package name must stay `tabby-agent-recovery` (`tabby-plugin` keyword) so the Plugin Manager lists it.
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
- ## What it does
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
- - On save, bind a local pane to `agent-sessions attach <key>` when that launcher can identify the process.
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
- Without `agent-sessions`, already-bound reconnect commands (including scripts in `<state>/reconnect/*.sh`) still restore. Ordinary bash panes are left alone.
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
- ## Optional companion
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
- An optional local `agent-sessions` launcher (`~/.local/bin/agent-sessions`) can keep agent dialogs alive in tmux when Tabby closes. The plugin talks to it through:
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
- | Env | Default |
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, else `~/.local/state/tabby-agent-recovery` |
41
- | `TABBY_AGENT_RECOVERY_LOG=1` | write pane output to `<state>/terminal-logs/` (off by default) |
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
- State files are mode `0600`. They can contain paths, commands, and (if logging is on) terminal output. Nothing is uploaded.
65
+ Set these in the environment that launches Tabby. No configuration is needed for ordinary standalone use.
44
66
 
45
- ## Already using the local copy
67
+ ## Privacy
46
68
 
47
- If Tabby already loads this plugin from a symlink under `~/.config/tabby/plugins/node_modules/tabby-agent-recovery`, do not also install it from the Plugin Manager — npm would replace that symlink.
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="$(pwd)" tabby --debug
75
+ TABBY_PLUGINS="$PWD" tabby --debug
54
76
  ```
55
77
 
56
- ## Publish
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, warning = console.warn,
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
- if (key && state().entries[key]?.enabled !== false) {
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 (!key || this.session || state().entries[key]?.enabled === false) return
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.0.0",
4
- "description": "Restore Tabby panes after restart: reattach surviving sessions, keep splits, and stop tabs from vanishing when a process exits.",
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
- "screenshot.png"
20
+ "CHANGELOG.md"
20
21
  ],
21
22
  "scripts": {
22
- "test": "node --test test/bridge.test.cjs",
23
+ "test": "node --test test/*.test.cjs",
23
24
  "prepublishOnly": "npm test"
24
25
  },
25
26
  "repository": {
package/screenshot.png DELETED
Binary file