File size: 5,207 Bytes
df112f9
dce99f0
 
 
 
 
df112f9
dce99f0
 
df112f9
 
 
 
 
 
 
 
 
dce99f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df112f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dce99f0
 
 
df112f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dce99f0
 
 
df112f9
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const SAFE = /^[A-Za-z0-9_-]+$/;

function paneIdentity() {
  const amId = process.env.AM_ID;
  const runId = process.env.AM_RUN_ID;
  if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return null;
  if (String(process.pid) !== process.env.AM_PANE_PID) return null;
  return { amId, runId };
}

function report(sessionID, cwd, source) {
  const identity = paneIdentity();
  if (!identity) return;
  const { amId, runId } = identity;
  if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return;
  const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin');
  const file = path.join(dir, `${amId}.opencode.json`);
  const tmp = `${file}.${process.pid}.tmp`;
  try {
    // OpenCode dispatches generic event hooks without awaiting their Promise.
    // Keep this tiny local write synchronous so /clear followed immediately by
    // quit cannot terminate the process between mkdir/write/rename.
    mkdirSync(dir, { recursive: true });
    writeFileSync(tmp, JSON.stringify({
      amId,
      runId,
      cli: 'opencode',
      pluginPid: process.pid,
      payload: { session_id: sessionID, cwd, source },
    }));
    renameSync(tmp, file);
  } catch { /* telemetry must never interfere with the user's prompt */ }
}

function syncInputRequired(pending) {
  const identity = paneIdentity();
  if (!identity) return;
  const { amId, runId } = identity;
  const dir = process.env.AM_INPUT_REQUIRED_DIR || path.join(os.tmpdir(), 'am-input-required');
  const file = path.join(dir, `${amId}.json`);
  const item = pending.values().next().value;
  if (!item) {
    try {
      const existing = JSON.parse(readFileSync(file, 'utf8'));
      if (existing?.runId === runId && existing?.source === 'opencode-event') unlinkSync(file);
    } catch { /* already absent or no longer ours */ }
    return;
  }
  const tmp = `${file}.${process.pid}.tmp`;
  try {
    mkdirSync(dir, { recursive: true });
    writeFileSync(tmp, JSON.stringify({
      amId,
      runId,
      cli: 'opencode',
      kind: item.kind,
      source: 'opencode-event',
      requestId: item.id,
      at: item.at,
    }));
    renameSync(tmp, file);
  } catch { /* an attention signal must never interfere with the TUI */ }
}

// OpenCode creates a new root session for /new (alias /clear). chat.message
// additionally follows an explicit switch to an existing session; runner.js
// verifies that id against the database and rejects child/subagent sessions.
export const AgentManagerRepin = async ({ directory }) => {
  // OpenCode's own TUI uses the same asked/replied event pairs. Keep every
  // queued request: resolving one must not hide another waiting behind it.
  const pending = new Map();

  const changed = () => syncInputRequired(pending);
  const drop = (id) => {
    if (!id || !pending.delete(id)) return;
    changed();
  };

  return ({
    event: async ({ event }) => {
      const props = event?.properties || {};
      if (event?.type === 'session.created') {
        const info = props.info;
        if (info?.id && !info.parentID) report(info.id, info.directory || directory, 'session.created');
        return;
      }
      if (event?.type === 'permission.asked' || event?.type === 'question.asked') {
        if (!props.id || pending.has(props.id)) return;
        pending.set(props.id, {
          id: props.id,
          sessionID: props.sessionID,
          kind: event.type === 'question.asked' ? 'question' : 'permission',
          tool: props.tool || null,
          at: Date.now(),
        });
        changed();
        return;
      }
      if (event?.type === 'permission.replied'
        || event?.type === 'question.replied'
        || event?.type === 'question.rejected') {
        drop(props.requestID);
        return;
      }
      // A Question tool can complete without question.replied. OpenCode's TUI
      // has this same recovery path; mirror it so the marker cannot stick.
      if (event?.type === 'message.part.updated') {
        const part = props.part;
        if (part?.type !== 'tool' || part.tool !== 'question'
          || (part.state?.status !== 'completed' && part.state?.status !== 'error')) return;
        let dirty = false;
        for (const [id, item] of pending) {
          if (item.kind !== 'question' || !item.tool) continue;
          if (item.tool.messageID === part.messageID && item.tool.callID === part.callID) {
            pending.delete(id);
            dirty = true;
          }
        }
        if (dirty) changed();
      }
    },
    'chat.message': async ({ sessionID }) => {
      report(sessionID, directory, 'chat.message');
    },
  // Tool shells must not pass the pane's private attribution markers to an
  // agent launched inside them. Empty values override OpenCode's process.env
  // merge and make the nested plugin a no-op.
    'shell.env': async (_input, output) => {
      output.env.AM_ID = '';
      output.env.AM_RUN_ID = '';
      output.env.AM_CLI = '';
      output.env.AM_PANE_PID = '';
    },
  });
};