@quantiya/codevibe-codex-plugin 2.0.75 → 2.0.77

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/hooks/common.sh CHANGED
@@ -59,6 +59,7 @@ fi
59
59
 
60
60
  CODEVIBE_TMPDIR="${TMPDIR:-/tmp}"
61
61
  LOG_FILE="${LOG_FILE:-${CODEVIBE_TMPDIR}/codevibe-codex-hooks.log}"
62
+ CODEVIBE_HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
62
63
 
63
64
  log() {
64
65
  local level="$1"
@@ -525,9 +526,27 @@ ensure_codex_daemon() {
525
526
  return $rc
526
527
  }
527
528
 
529
+ bind_codex_hook_session() {
530
+ local binding session environment rc=0
531
+ binding=$(printf '%s' "$1" | node "$CODEVIBE_HOOKS_DIR/../libexec/resolve-hook-session.js") || rc=$?
532
+ case "$rc" in
533
+ 0) export CODEVIBE_HOOK_ALLOW_RECOVERY=1 ;;
534
+ 2) export CODEVIBE_HOOK_ALLOW_RECOVERY=0
535
+ log "WARN" "Hook metadata unavailable; retaining existing-port POST only, daemon recovery disabled" ;;
536
+ *) log "WARN" "Hook has no unique live wrapper binding; not posting or starting recovery"; return 1 ;;
537
+ esac
538
+ IFS=$'\t' read -r session environment <<< "$binding"
539
+ if [ "$session" != "$CODEVIBE_CODEX_TMUX_SESSION" ]; then
540
+ log "INFO" "Corrected stale hook wrapper binding: $CODEVIBE_CODEX_TMUX_SESSION -> $session"
541
+ fi
542
+ export CODEVIBE_CODEX_TMUX_SESSION="$session"
543
+ export ENVIRONMENT="$environment"
544
+ }
545
+
528
546
  send_to_server() {
529
547
  local endpoint="$1"
530
548
  local json_data="$2"
549
+ bind_codex_hook_session "$json_data" || return 1
531
550
 
532
551
  local server_url curl_exit http_code response
533
552
  server_url=$(get_server_url)
@@ -570,6 +589,10 @@ send_to_server() {
570
589
  log "ERROR" "Local curl invocation failed (exit $curl_exit) — not replacing the Codex daemon"
571
590
  return 1
572
591
  fi
592
+ if [ "${CODEVIBE_HOOK_ALLOW_RECOVERY:-1}" = "0" ]; then
593
+ log "WARN" "Hook transport unavailable and binding unverified; not replacing or launching a daemon"
594
+ return 1
595
+ fi
573
596
 
574
597
  # (#638) TRANSPORT failure = curl non-zero OR HTTP 000 (dead daemon / missing
575
598
  # port file). Relaunch the daemon ONCE and retry the POST once (one relaunch
@@ -627,4 +650,6 @@ export -f _reuse_live_codex_daemon
627
650
  export -f _codex_daemon_pid_on_port
628
651
  export -f _codex_candidate_is_excluded
629
652
  export -f ensure_codex_daemon
653
+ export CODEVIBE_HOOKS_DIR
654
+ export -f bind_codex_hook_session
630
655
  export -f send_to_server
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { execFileSync } = require('child_process');
5
+ const { parse } = require('shell-quote');
6
+ const nativeUuid = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
7
+ const validNativeId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{1,180}$/.test(id);
8
+ const environmentFor = (value) => value === 'development' || value === 'experiment' ? value : 'production';
9
+
10
+ // pane_start_command uses tmux args_escape/vis, not JSON. Decode bytes without
11
+ // evaluating shell text; literal UTF-8 runs remain UTF-8 and octal runs are bytes.
12
+ function decodeTmuxCommand(command) {
13
+ if (!command.startsWith('"')) return command;
14
+ if (!command.endsWith('"')) throw new Error('incomplete tmux quoting');
15
+ const body = command.slice(1, -1);
16
+ const chunks = [];
17
+ const escapes = { '\\': 92, '"': 34, '$': 36, '~': 126, a: 7, b: 8, e: 27,
18
+ f: 12, n: 10, r: 13, s: 32, t: 9, v: 11 };
19
+ let cursor = 0;
20
+ const literal = (text) => {
21
+ if (text.includes('\\')) throw new Error('unknown tmux escape');
22
+ chunks.push(Buffer.from(text));
23
+ };
24
+ for (const match of body.matchAll(/\\([0-7]{1,3}|[\\"$~abefnrstv])/g)) {
25
+ literal(body.slice(cursor, match.index));
26
+ const value = /^[0-7]/.test(match[1]) ? parseInt(match[1], 8) : escapes[match[1]];
27
+ chunks.push(Buffer.from([value]));
28
+ cursor = match.index + match[0].length;
29
+ }
30
+ literal(body.slice(cursor));
31
+ return Buffer.concat(chunks).toString('utf8');
32
+ }
33
+
34
+ function inheritedFallback(nativeId, session, environment) {
35
+ if (!validNativeId(nativeId) || !/^codevibe-codex-[1-9]\d*$/.test(session || '')) return null;
36
+ // Preserve the old existing-port POST on unavailable metadata, but never
37
+ // launch/replace a daemon without a verified binding.
38
+ return { session, environment: environmentFor(environment), allowRecovery: false };
39
+ }
40
+
41
+ // Resumed Codex shell snapshots can retain a previous wrapper's environment.
42
+ // Match the hook's native id to an explicit, live wrapper resume command instead
43
+ // of launching recovery for that stale name. Never evaluate pane command text.
44
+ function wrapperCommand(session, command) {
45
+ if (!/^codevibe-codex-[1-9]\d*$/.test(session)) return null;
46
+ let words;
47
+ try {
48
+ command = decodeTmuxCommand(command);
49
+ words = parse(command);
50
+ if (words.length === 1 && typeof words[0] === 'string') words = parse(words[0]);
51
+ } catch { return { undecodable: true }; }
52
+ const statements = [[]];
53
+ for (const word of words) {
54
+ if (typeof word === 'object' && word.op === ';') statements.push([]);
55
+ else statements[statements.length - 1].push(word);
56
+ }
57
+ const hasExport = (value) => statements.some((s) => s.length === 2 && s[0] === 'export' && s[1] === value);
58
+ if (!hasExport(`CODEVIBE_CODEX_TMUX_SESSION=${session}`)
59
+ || !hasExport('CODEVIBE_CODEX_PRIMARY_COMPANION=1')) return null;
60
+ const environment = statements.find((s) => s.length === 2 && s[0] === 'export'
61
+ && typeof s[1] === 'string' && s[1].startsWith('ENVIRONMENT='));
62
+ const codex = statements.filter((s) => s[0] === 'codex');
63
+ if (!environment || codex.length !== 1 || !codex[0].every((w) => typeof w === 'string')) return null;
64
+ return { session, environment: environmentFor(environment[1].slice('ENVIRONMENT='.length)), args: codex[0].slice(1) };
65
+ }
66
+
67
+ function resolveHookSession(nativeId, inheritedSession, inventory, inheritedEnvironment) {
68
+ if (!validNativeId(nativeId)) return null;
69
+ const entries = inventory.split('\n').filter(Boolean).flatMap((line) => {
70
+ const [session, dead, ...command] = line.split('\t');
71
+ if (dead !== '0') return [];
72
+ const wrapper = wrapperCommand(session, command.join('\t'));
73
+ return wrapper ? [{ session, wrapper }] : [];
74
+ });
75
+ const wrappers = entries.filter((e) => !e.wrapper.undecodable).map((e) => e.wrapper);
76
+ const resumed = wrappers.filter((w) => w.args[0] === 'resume' && w.args[1] === nativeId);
77
+ if (resumed.length > 1) return null;
78
+ const inherited = wrappers.filter((w) => w.session === inheritedSession);
79
+ if (entries.filter((e) => e.session === inheritedSession).length === 1
80
+ && entries.some((e) => e.session === inheritedSession && e.wrapper.undecodable)) {
81
+ return inheritedFallback(nativeId, inheritedSession, inheritedEnvironment);
82
+ }
83
+ // A live fresh/picker wrapper is not proven stale. Preserve it rather than
84
+ // redirecting its hooks merely because another wrapper resumes this history.
85
+ if (inherited.length === 1 && !(inherited[0].args[0] === 'resume'
86
+ && nativeUuid.test(inherited[0].args[1] || '')
87
+ && inherited[0].args[1].toLowerCase() !== nativeId.toLowerCase())) return inherited[0];
88
+ return resumed.length === 1 ? resumed[0] : null;
89
+ }
90
+
91
+ if (require.main === module) {
92
+ let input;
93
+ const emit = (binding) => {
94
+ if (!binding) { process.exitCode = 1; return; }
95
+ process.stdout.write(`${binding.session}\t${binding.environment}\n`);
96
+ if (binding.allowRecovery === false) process.exitCode = 2;
97
+ };
98
+ try {
99
+ input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
100
+ const socket = (process.env.TMUX || '').split(',')[0];
101
+ const args = socket ? ['-S', socket] : [];
102
+ if (socket && !socket.startsWith('/')) throw new Error('invalid tmux socket');
103
+ const inventory = execFileSync('tmux', [...args, 'list-panes', '-a', '-F',
104
+ '#{session_name}\t#{pane_dead}\t#{pane_start_command}'], {
105
+ encoding: 'utf8', timeout: 2000, maxBuffer: 1024 * 1024,
106
+ });
107
+ emit(resolveHookSession(input.session_id, process.env.CODEVIBE_CODEX_TMUX_SESSION, inventory, process.env.ENVIRONMENT));
108
+ } catch {
109
+ emit(inheritedFallback(input?.session_id, process.env.CODEVIBE_CODEX_TMUX_SESSION, process.env.ENVIRONMENT));
110
+ }
111
+ }
112
+
113
+ module.exports = { resolveHookSession };
@@ -799,6 +799,9 @@ export declare class AppSyncClient {
799
799
  * subscribeToEvents path below uses this same factory for all providers. */
800
800
  desktopSyncLiveSession(options: DesktopSyncSessionScope & {
801
801
  root?: string;
802
+ revalidateRegistration?: boolean;
803
+ onRegistrationRevalidated?(): void;
804
+ onUpgradeRejected?(): void;
802
805
  }, onEvent: (event: Event) => Promise<void> | void, onError: (error: Error) => void, signal?: AbortSignal): Promise<DesktopSyncLiveConnection>;
803
806
  private syncEventForConsumer;
804
807
  retireDesktopSyncRegistration(options: DesktopSyncSessionScope & {
@@ -52,6 +52,9 @@ export declare class DesktopSyncLiveConnection {
52
52
  onUncertain?(count: number): void;
53
53
  retryExecutionAcknowledgement?(target: SyncRecoveryTarget, signal: AbortSignal): Promise<SyncExecutedInput>;
54
54
  onMaintenance?(signal: AbortSignal): Promise<void>;
55
+ revalidateRegistration?: boolean;
56
+ onRegistrationRevalidated?(): void;
57
+ onUpgradeRejected?(): void;
55
58
  onEvent(event: SyncReadEvent): Promise<void> | void;
56
59
  onError(error: Error): void;
57
60
  openSocket?: (url: string, options: WebSocket.ClientOptions) => WebSocket;