@ian-pascoe/pi-termctrl 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -92,7 +92,7 @@ another fails and lists the caller's live entries.
92
92
  `terminal_stop` stops a running entry (termctrl stop, then `SIGKILL` of the
93
93
  Terminal's process group if it is still alive after 3 s) and forgets it. For a
94
94
  Terminal it returns the final screen and scrolled-off lines; for a Background
95
- job, its recent output. When a Terminal had already exited and its screen is
95
+ job, its recent output. When a Terminal's screen is
96
96
  unchanged since the agent's last result, the result omits the screen and reports
97
97
  `changed: false` with `state` and `exit_code` or `signal`; `scrolled_off` still
98
98
  appears when lines scrolled off since that result. Exited entries stay
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -36,19 +36,38 @@ async function readPid(pidFile: string): Promise<number | undefined> {
36
36
  return undefined;
37
37
  }
38
38
 
39
+ /**
40
+ * The requests made to one driver. termctrl answers every request sent before its shutdown request
41
+ * and none sent after it, and the SDK sends a request while its close is in flight without ever
42
+ * failing it, so that request would wait forever. The registry closes an idle driver without
43
+ * waiting, and a tool call may still read one of its exited Terminals, as `terminal_stop` does.
44
+ * Requests are therefore refused once closing starts, with the SDK's message for a closed driver.
45
+ */
46
+ class DriverRequests {
47
+ private closing = false;
48
+
49
+ send<T>(request: () => Promise<T>): Promise<T> {
50
+ return this.closing ? Promise.reject(new Error("termctrl driver is closed")) : request();
51
+ }
52
+
53
+ close(shutdown: () => Promise<void>): Promise<void> {
54
+ this.closing = true;
55
+ return shutdown();
56
+ }
57
+ }
58
+
39
59
  class TermctrlTerminal implements TerminalHandle {
40
60
  constructor(
41
61
  private readonly session: Session,
62
+ private readonly requests: DriverRequests,
42
63
  private readonly pid: number | undefined,
43
64
  ) {}
44
65
 
45
66
  async snapshot(): Promise<TerminalSnapshot> {
46
- const status = await this.session.status();
47
- const capture = await this.session.screen.capture({
48
- allowIncomplete: true,
49
- settleMs: 0,
50
- deadlineMs: 0,
51
- });
67
+ const status = await this.requests.send(() => this.session.status());
68
+ const capture = await this.requests.send(() =>
69
+ this.session.screen.capture({ allowIncomplete: true, settleMs: 0, deadlineMs: 0 }),
70
+ );
52
71
  return {
53
72
  screen: capture.text,
54
73
  state: status.state,
@@ -58,19 +77,19 @@ class TermctrlTerminal implements TerminalHandle {
58
77
  }
59
78
 
60
79
  logs(): Promise<string> {
61
- return this.session.logs.text();
80
+ return this.requests.send(() => this.session.logs.text());
62
81
  }
63
82
 
64
83
  type(text: string): Promise<void> {
65
- return this.session.keyboard.type(text);
84
+ return this.requests.send(() => this.session.keyboard.type(text));
66
85
  }
67
86
 
68
87
  press(keys: readonly Key[]): Promise<void> {
69
- return this.session.keyboard.sequence(keys);
88
+ return this.requests.send(() => this.session.keyboard.sequence(keys));
70
89
  }
71
90
 
72
91
  stop(): Promise<void> {
73
- return this.session.stop();
92
+ return this.requests.send(() => this.session.stop());
74
93
  }
75
94
 
76
95
  isAlive(): boolean {
@@ -98,6 +117,8 @@ class TermctrlTerminal implements TerminalHandle {
98
117
  }
99
118
 
100
119
  class TermctrlDriver implements TerminalDriver {
120
+ private readonly requests = new DriverRequests();
121
+
101
122
  constructor(private readonly control: TerminalControl) {}
102
123
 
103
124
  async launch(request: TerminalLaunchRequest): Promise<TerminalHandle> {
@@ -105,19 +126,21 @@ class TermctrlDriver implements TerminalDriver {
105
126
  await mkdir(directory, { recursive: true });
106
127
  const pidFile = join(directory, `${process.pid}-${request.id}.pid`);
107
128
  try {
108
- const session = await this.control.launch({
109
- command: ["/bin/sh", "-c", PID_WRAPPER, "pi-termctrl", pidFile, ...request.command],
110
- cwd: request.cwd,
111
- viewport: request.viewport,
112
- });
113
- return new TermctrlTerminal(session, await readPid(pidFile));
129
+ const session = await this.requests.send(() =>
130
+ this.control.launch({
131
+ command: ["/bin/sh", "-c", PID_WRAPPER, "pi-termctrl", pidFile, ...request.command],
132
+ cwd: request.cwd,
133
+ viewport: request.viewport,
134
+ }),
135
+ );
136
+ return new TermctrlTerminal(session, this.requests, await readPid(pidFile));
114
137
  } finally {
115
138
  await rm(pidFile, { force: true });
116
139
  }
117
140
  }
118
141
 
119
142
  close(): Promise<void> {
120
- return this.control.close();
143
+ return this.requests.close(() => this.control.close());
121
144
  }
122
145
  }
123
146
 
@@ -140,7 +140,7 @@ const StopResultSchema = Type.Object({
140
140
  screen: Type.Optional(
141
141
  Type.String({
142
142
  description:
143
- "Terminal only: the final screen; omitted for an exited Terminal whose screen is unchanged since your previous result",
143
+ "Terminal only: the final screen; omitted when unchanged since your previous result",
144
144
  }),
145
145
  ),
146
146
  scrolled_off: Type.Optional(
@@ -815,10 +815,11 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
815
815
  const parts = [header];
816
816
  if (entry.kind === "terminal") {
817
817
  const screen = entry.finalScreen ?? "";
818
- // An exited Terminal's screen the agent already saw is not repeated.
819
- const repeated = !wasRunning && previousScreen === screen;
818
+ const changed = previousScreen !== screen;
819
+ // A screen the agent already saw is not repeated, whether the Terminal was running or exited.
820
+ const repeated = !changed;
820
821
  const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
821
- result.changed = previousScreen !== screen;
822
+ result.changed = changed;
822
823
  if (!repeated) result.screen = output.screen;
823
824
  if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
824
825
  if (output.gap) result.output_missing = true;