nterminal 1.3.2 → 1.3.3

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.
Files changed (61) hide show
  1. package/README.md +4 -2
  2. package/bin/nterminal.js +5 -0
  3. package/dist/client/assets/MarkdownPreview-CWK2qIZp.js +1 -0
  4. package/dist/client/assets/{TranscriptMarkdownBody-Cu7KK3bx.js → TranscriptMarkdownBody-Ddi-rw6X.js} +1 -1
  5. package/dist/client/assets/index-CcAQThtj.js +56 -0
  6. package/dist/client/assets/{index-CosXAR2f.js → index-DDCpqDNb.js} +1 -1
  7. package/dist/client/assets/{index-lQ2cyEWO.css → index-DTTX24tu.css} +1 -1
  8. package/dist/client/index.html +2 -2
  9. package/dist/scripts/onboarding.js +9 -0
  10. package/dist/scripts/onboarding.js.map +1 -1
  11. package/dist/server/agent/agentProxy.d.ts +1 -0
  12. package/dist/server/agent/agentProxy.js +3 -1
  13. package/dist/server/agent/agentProxy.js.map +1 -1
  14. package/dist/server/agent/agentRoutes.js +16 -2
  15. package/dist/server/agent/agentRoutes.js.map +1 -1
  16. package/dist/server/index.js +2 -0
  17. package/dist/server/index.js.map +1 -1
  18. package/dist/server/routes/agentManagementRoutes.js +13 -2
  19. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  20. package/dist/server/routes/terminalRoutes.js +19 -2
  21. package/dist/server/routes/terminalRoutes.js.map +1 -1
  22. package/dist/server/routes/transcriptQuery.d.ts +14 -0
  23. package/dist/server/routes/transcriptQuery.js +57 -0
  24. package/dist/server/routes/transcriptQuery.js.map +1 -0
  25. package/dist/server/terminal/PtyAdapter.d.ts +12 -0
  26. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  27. package/dist/server/terminal/TerminalManager.d.ts +6 -7
  28. package/dist/server/terminal/TerminalManager.js +57 -11
  29. package/dist/server/terminal/TerminalManager.js.map +1 -1
  30. package/dist/server/terminal/TmuxPtyAdapter.d.ts +15 -1
  31. package/dist/server/terminal/TmuxPtyAdapter.js +284 -13
  32. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  33. package/dist/server/terminal/codexTranscriptSource.d.ts +18 -1
  34. package/dist/server/terminal/codexTranscriptSource.js +263 -68
  35. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  36. package/dist/server/terminal/sqliteReader.d.ts +21 -0
  37. package/dist/server/terminal/sqliteReader.js +158 -0
  38. package/dist/server/terminal/sqliteReader.js.map +1 -0
  39. package/dist/server/terminal/subagentSources.js +3 -2
  40. package/dist/server/terminal/subagentSources.js.map +1 -1
  41. package/dist/server/terminal/transcriptHistory.d.ts +34 -0
  42. package/dist/server/terminal/transcriptHistory.js +81 -36
  43. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  44. package/dist/server/terminal/transcriptStore.d.ts +64 -0
  45. package/dist/server/terminal/transcriptStore.js +779 -0
  46. package/dist/server/terminal/transcriptStore.js.map +1 -0
  47. package/dist/shared/nodeVersion.d.ts +3 -0
  48. package/dist/shared/nodeVersion.js +12 -0
  49. package/dist/shared/nodeVersion.js.map +1 -0
  50. package/dist/shared/protocol.d.ts +33 -0
  51. package/dist/shared/protocol.js.map +1 -1
  52. package/dist/shared/transcriptSearch.d.ts +2 -0
  53. package/dist/shared/transcriptSearch.js +12 -0
  54. package/dist/shared/transcriptSearch.js.map +1 -0
  55. package/dist/src/shared/nodeVersion.js +12 -0
  56. package/dist/src/shared/nodeVersion.js.map +1 -0
  57. package/docs/terminal-history.md +22 -4
  58. package/package.json +2 -2
  59. package/scripts/nterminalctl +8 -4
  60. package/dist/client/assets/MarkdownPreview-CzA5ZyZ3.js +0 -1
  61. package/dist/client/assets/index-wiyTLXQg.js +0 -56
@@ -1,13 +1,13 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
- import { access, open as openFile, readFile, readdir, realpath, stat } from 'node:fs/promises';
4
+ import { access, open as openFile, opendir, readFile, readdir, readlink, realpath, stat } from 'node:fs/promises';
5
5
  import os from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import * as nodePty from 'node-pty';
8
8
  import path from 'node:path';
9
9
  import { normalizeTerminalTitle, PtySessionUnavailableError } from './PtyAdapter.js';
10
- import { resolveCodexTranscriptFromDaemon, resolveCodexTranscriptFromOpenFds, resolveCodexTranscriptFromProcessLogs } from './codexTranscriptSource.js';
10
+ import { lookupCodexTranscriptFromDaemonOrLogs, lookupCodexTranscriptFromOpenFds, resolveCodexTranscriptCandidates, processBinding, TRANSCRIPT_CANDIDATE_LIMIT } from './codexTranscriptSource.js';
11
11
  import { isSessionId } from './sessionId.js';
12
12
  import { processStartTicksToSecondsAsync, readProcessCwdAsync, readProcessEnvValuesAsync, readProcessStartTicksAsync } from './procfs.js';
13
13
  import { TtlCache } from './ttlCache.js';
@@ -26,6 +26,7 @@ const TRANSCRIPT_FILE_NEGATIVE_CACHE_TTL_MS = 20_000;
26
26
  // letting a history-paging burst (several page requests within seconds)
27
27
  // reuse one resolution instead of re-forking tmux/ps per page.
28
28
  const TRANSCRIPT_RESOLUTION_CACHE_TTL_MS = 3_000;
29
+ const TRANSCRIPT_CANDIDATE_CACHE_TTL_MS = 500;
29
30
  const PROCESS_TREE_CACHE_TTL_MS = 1_000;
30
31
  const CLAUDE_TRANSCRIPT_START_SKEW_MS = 10_000;
31
32
  const CLAUDE_TRANSCRIPT_SAMPLE_BYTES = 512 * 1024;
@@ -75,6 +76,7 @@ export class TmuxPtyAdapter {
75
76
  ptySpawner;
76
77
  transcriptResolutionCache = new TtlCache();
77
78
  transcriptResolutionInflight = new KeyedSingleFlight();
79
+ transcriptCandidateInflight = new KeyedSingleFlight();
78
80
  processTreeCache = new TtlCache();
79
81
  processTreeInflight = new KeyedSingleFlight();
80
82
  historyTokenGeneration = randomBytes(16).toString('hex');
@@ -1047,6 +1049,72 @@ export class TmuxPtyAdapter {
1047
1049
  return source;
1048
1050
  });
1049
1051
  }
1052
+ async resolveTranscriptCandidates(sessionId) {
1053
+ return this.transcriptCandidateInflight.run(sessionId, async () => {
1054
+ const result = await this.resolveTranscriptCandidatesUncached(sessionId);
1055
+ return { binding: result.binding, candidates: result.candidates.map(candidate => ({ ...candidate })) };
1056
+ });
1057
+ }
1058
+ async resolveTranscriptCandidatesUncached(sessionId) {
1059
+ const empty = { binding: '', candidates: [] };
1060
+ const panePid = await this.readPanePidAsync(sessionId);
1061
+ if (panePid === null)
1062
+ return empty;
1063
+ const paneStart = await readProcessStartTicksAsync(panePid);
1064
+ if (paneStart === null)
1065
+ return empty;
1066
+ const tree = await this.readProcessTreeSnapshot();
1067
+ if (!tree)
1068
+ return empty;
1069
+ const argsByPid = new Map();
1070
+ const processes = listProcessPidsWithDepth(tree, panePid);
1071
+ await mapWithConcurrency(processes, 8, async ({ pid }) => argsByPid.set(pid, await readProcessArgsAsync(pid)));
1072
+ const discoveries = [];
1073
+ for (const { pid, depth } of processes) {
1074
+ const args = argsByPid.get(pid);
1075
+ if (findCommandArg(args, 'claude') !== -1) {
1076
+ if (isNonInteractiveClaudeInvocation(args))
1077
+ continue;
1078
+ const result = await resolveClaudeTranscriptCandidates(pid, { args, reuseCache: true });
1079
+ if (!result.binding)
1080
+ return empty;
1081
+ if (result.rank !== 'live' && listProcessPidsWithDepth(tree, pid).some(child => child.pid !== pid && findCommandArg(argsByPid.get(child.pid) ?? [], 'claude') !== -1 &&
1082
+ !isNonInteractiveClaudeInvocation(argsByPid.get(child.pid) ?? [])))
1083
+ continue;
1084
+ discoveries.push({ depth, result });
1085
+ continue;
1086
+ }
1087
+ if (findCommandArg(args, 'codex') === -1 || isNonInteractiveCodexInvocation(args))
1088
+ continue;
1089
+ if (hasCodexDescendant(tree, pid, argsByPid) &&
1090
+ (await lookupCodexTranscriptFromOpenFds(pid)).status === 'absent')
1091
+ continue;
1092
+ const startTicks = await readProcessStartTicksAsync(pid);
1093
+ if (startTicks === null)
1094
+ return empty;
1095
+ const result = await resolveCodexTranscriptCandidates(pid, {
1096
+ processStartedAtSeconds: await processStartTicksToSecondsAsync(startTicks)
1097
+ });
1098
+ if (!result.binding || startTicks !== await readProcessStartTicksAsync(pid))
1099
+ return empty;
1100
+ discoveries.push({ depth, result });
1101
+ }
1102
+ if (paneStart !== await readProcessStartTicksAsync(panePid) || panePid !== await this.readPanePidAsync(sessionId))
1103
+ return empty;
1104
+ const closest = Math.min(...discoveries.map(({ depth }) => depth));
1105
+ const candidates = new Map();
1106
+ for (const { depth, result } of discoveries) {
1107
+ if (depth !== closest)
1108
+ continue;
1109
+ for (const candidate of result.candidates) {
1110
+ candidates.set(`${candidate.provider}:${candidate.sessionId}`, candidate);
1111
+ }
1112
+ }
1113
+ return {
1114
+ binding: `pane:${panePid}:${paneStart}|${discoveries.map(({ result }) => result.binding).sort().join('|')}`,
1115
+ candidates: [...candidates.values()].slice(0, TRANSCRIPT_CANDIDATE_LIMIT)
1116
+ };
1117
+ }
1050
1118
  async resolveTranscriptSourceUncached(sessionId) {
1051
1119
  const panePid = this.panePidBySession.get(sessionId) ?? await this.readPanePidAsync(sessionId);
1052
1120
  if (panePid === null) {
@@ -1093,11 +1161,13 @@ export class TmuxPtyAdapter {
1093
1161
  // The append-open rollout is the authoritative owner signal. Check
1094
1162
  // it before descendant suppression: recent Codex releases spawn
1095
1163
  // helpers such as codex-code-mode-host beneath the real process.
1096
- const live = await resolveCodexTranscriptFromOpenFds(pid);
1097
- if (live) {
1098
- entries.push({ candidate: live, rank: 'live', processDepth });
1164
+ const live = await lookupCodexTranscriptFromOpenFds(pid);
1165
+ if (live.status === 'matched') {
1166
+ entries.push({ candidate: live.source, rank: 'live', processDepth });
1099
1167
  continue;
1100
1168
  }
1169
+ if (live.status === 'pending' || live.status === 'ambiguous')
1170
+ return null;
1101
1171
  // Launcher pids (the npm node wrapper) merely relay the binary's
1102
1172
  // argv; after /new their stale `resume <id>` args would conflict
1103
1173
  // with the live session the binary itself reports, so only the
@@ -1105,14 +1175,13 @@ export class TmuxPtyAdapter {
1105
1175
  if (hasCodexDescendant(tree, pid, argsByPid)) {
1106
1176
  continue;
1107
1177
  }
1108
- // Process logs reflect the thread the process wrote to most
1109
- // recently, so they outrank launch args, which freeze at startup.
1110
- // Daemon-backed TUIs (Codex >= 0.157) leave neither an fd nor
1111
- // thread-tagged logs of their own; their daemon's open rollouts come
1112
- // next, still ahead of args that go stale after /new or /resume.
1113
- const stated = (await resolveCodexTranscriptFromProcessLogs(pid)) ??
1114
- (await resolveCodexTranscriptFromDaemon(pid)) ??
1115
- extractCodexArgsCandidate(args);
1178
+ // A connected daemon is authoritative, even when its current
1179
+ // thread is pending/ambiguous. Never replace that with stale argv.
1180
+ const lookup = await lookupCodexTranscriptFromDaemonOrLogs(pid);
1181
+ if (lookup.status === 'pending' || lookup.status === 'ambiguous')
1182
+ return null;
1183
+ const stated = lookup.status === 'matched' ? lookup.source
1184
+ : lookup.status === 'absent' ? extractCodexArgsCandidate(args) : null;
1116
1185
  if (stated) {
1117
1186
  entries.push({ candidate: stated, rank: 'stated', processDepth });
1118
1187
  }
@@ -1815,6 +1884,208 @@ async function resolveClaudeCandidateForPidAsync(pid, args) {
1815
1884
  });
1816
1885
  return { entry: inferred ? { candidate: inferred, rank: 'inferred' } : null, pending: false };
1817
1886
  }
1887
+ const claudeCandidateCache = new TtlCache();
1888
+ const claudeCandidateInflight = new KeyedSingleFlight();
1889
+ const CLAUDE_CANDIDATE_DIRECTORY_LIMIT = 500;
1890
+ const CLAUDE_CANDIDATE_SAMPLE_BYTES = 32 * 1024;
1891
+ export function isNonInteractiveClaudeInvocation(args) {
1892
+ const index = findCommandArg(args, 'claude');
1893
+ if (index === -1)
1894
+ return true;
1895
+ const rest = args.slice(index + 1);
1896
+ return rest.some(arg => arg === '-p' || arg === '--print' || arg.startsWith('--print=')) ||
1897
+ ['mcp', 'auth', 'doctor', 'install', 'update', 'setup-token'].includes(rest[0] ?? '');
1898
+ }
1899
+ export async function resolveClaudeTranscriptCandidates(pid, options = {}) {
1900
+ const empty = { binding: '', candidates: [], exact: null, rank: 'inferred' };
1901
+ if (!Number.isInteger(pid) || pid <= 0)
1902
+ return empty;
1903
+ const procRoot = options.procRoot ?? '/proc';
1904
+ const processDir = path.join(procRoot, String(pid));
1905
+ const binding = await processBinding(procRoot, [pid]);
1906
+ if (!binding)
1907
+ return empty;
1908
+ let args, cwd, env;
1909
+ try {
1910
+ args = options.args ?? (await readFile(path.join(processDir, 'cmdline'), 'utf8')).split('\0').filter(Boolean);
1911
+ cwd = await readlink(path.join(processDir, 'cwd'));
1912
+ env = new Map((await readFile(path.join(processDir, 'environ'), 'utf8')).split('\0').map(entry => {
1913
+ const separator = entry.indexOf('=');
1914
+ return [entry.slice(0, separator), entry.slice(separator + 1)];
1915
+ }));
1916
+ }
1917
+ catch {
1918
+ return empty;
1919
+ }
1920
+ if (isNonInteractiveClaudeInvocation(args))
1921
+ return { ...empty, binding };
1922
+ const configRoot = env.get('CLAUDE_CONFIG_DIR') || path.join(env.get('HOME') || os.homedir(), '.claude');
1923
+ const registryPath = path.join(configRoot, 'sessions', `${pid}.json`);
1924
+ const version = async (filePath) => {
1925
+ try {
1926
+ const info = await stat(filePath);
1927
+ return [info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs];
1928
+ }
1929
+ catch {
1930
+ return null;
1931
+ }
1932
+ };
1933
+ // Registry switches and project membership changes invalidate even the brief
1934
+ // metadata cache. Appending a transcript does not change its process binding.
1935
+ const evidenceVersion = options.reuseCache ? await Promise.all([
1936
+ version(registryPath), version(path.join(configRoot, 'projects', encodeClaudeProjectPath(cwd)))
1937
+ ]) : null;
1938
+ const key = JSON.stringify([procRoot, binding, configRoot, cwd, args, options.processStartedAtMs, evidenceVersion]);
1939
+ const discover = async () => {
1940
+ const cached = options.reuseCache ? claudeCandidateCache.get(key)?.value : undefined;
1941
+ if (cached)
1942
+ return cached;
1943
+ const startTicks = binding.slice(binding.indexOf(':') + 1);
1944
+ // Fake procfs never consults the host clock or invokes getconf.
1945
+ const startMs = options.processStartedAtMs !== undefined ? options.processStartedAtMs
1946
+ : options.procRoot ? null : await processStartTicksToSecondsAsync(startTicks).then(value => value === null ? null : value * 1000);
1947
+ const result = await discoverClaudeProjectCandidates({ pid, binding, configRoot, cwd, args, startMs, startTicks });
1948
+ if (options.reuseCache)
1949
+ claudeCandidateCache.set(key, result, TRANSCRIPT_CANDIDATE_CACHE_TTL_MS);
1950
+ return result;
1951
+ };
1952
+ const result = options.reuseCache ? await claudeCandidateInflight.run(key, discover) : await discover();
1953
+ try {
1954
+ if (binding !== await processBinding(procRoot, [pid]) || cwd !== await readlink(path.join(processDir, 'cwd')))
1955
+ return empty;
1956
+ }
1957
+ catch {
1958
+ return empty;
1959
+ }
1960
+ return { ...result, candidates: result.candidates.map(candidate => ({ ...candidate })) };
1961
+ }
1962
+ async function discoverClaudeProjectCandidates(options) {
1963
+ const result = { binding: options.binding, candidates: [], exact: null, rank: 'inferred' };
1964
+ let cwds = await normalizeClaudeCwdCandidatesAsync([options.cwd]);
1965
+ let sessionId;
1966
+ try {
1967
+ const file = await openFile(path.join(options.configRoot, 'sessions', `${options.pid}.json`), 'r');
1968
+ try {
1969
+ const info = await file.stat();
1970
+ if (info.size > 16 * 1024)
1971
+ return { ...result, rank: 'live' };
1972
+ const buffer = Buffer.alloc(info.size);
1973
+ await readExactly(file, buffer, 0);
1974
+ const record = JSON.parse(buffer.toString('utf8'));
1975
+ if (record && Number(record.pid) === options.pid && String(record.procStart) === options.startTicks) {
1976
+ if (record.kind !== undefined && record.kind !== 'interactive')
1977
+ return result;
1978
+ result.rank = 'live';
1979
+ if (!isSessionId(record.sessionId))
1980
+ return result;
1981
+ const registryCwds = typeof record.cwd === 'string' ? await normalizeClaudeCwdCandidatesAsync([record.cwd]) : [];
1982
+ if (registryCwds.length === 0)
1983
+ return result;
1984
+ // A live process can change cwd after opening its conversation. The
1985
+ // start-tick-matched registry is stronger evidence than current cwd.
1986
+ cwds = registryCwds;
1987
+ sessionId = record.sessionId;
1988
+ }
1989
+ }
1990
+ finally {
1991
+ await file.close();
1992
+ }
1993
+ }
1994
+ catch { /* Missing/stale registry permits bounded current-project evidence. */ }
1995
+ if (!sessionId) {
1996
+ sessionId = extractClaudeArgsCandidate(options.args)?.sessionId;
1997
+ if (sessionId)
1998
+ result.rank = 'stated';
1999
+ }
2000
+ const projectDirs = [...new Set(cwds.map(cwd => path.join(options.configRoot, 'projects', encodeClaudeProjectPath(cwd))))];
2001
+ let incomplete = false;
2002
+ if (sessionId) {
2003
+ // A confirmed pending registry must not fall back to another session.
2004
+ for (const projectDir of projectDirs) {
2005
+ const candidate = await describeClaudeCandidate(path.join(projectDir, `${sessionId}.jsonl`), sessionId, cwds, undefined, false);
2006
+ if (candidate)
2007
+ result.candidates.push(candidate);
2008
+ }
2009
+ }
2010
+ else if (options.startMs !== null) {
2011
+ let inspected = 0;
2012
+ for (const projectDir of projectDirs) {
2013
+ try {
2014
+ const directory = await opendir(projectDir);
2015
+ for await (const entry of directory) {
2016
+ if (++inspected > CLAUDE_CANDIDATE_DIRECTORY_LIMIT || result.candidates.length >= TRANSCRIPT_CANDIDATE_LIMIT) {
2017
+ incomplete = true;
2018
+ break;
2019
+ }
2020
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
2021
+ continue;
2022
+ const id = entry.name.slice(0, -6);
2023
+ if (!isSessionId(id))
2024
+ continue;
2025
+ const candidate = await describeClaudeCandidate(path.join(projectDir, entry.name), id, cwds, options.startMs);
2026
+ if (candidate)
2027
+ result.candidates.push(candidate);
2028
+ }
2029
+ }
2030
+ catch {
2031
+ incomplete = true;
2032
+ }
2033
+ if (incomplete)
2034
+ break;
2035
+ }
2036
+ }
2037
+ // Never rank by mtime. Unknown/unreadable peers also veto uniqueness.
2038
+ if (!incomplete && result.candidates.length === 1 && result.candidates[0].readable &&
2039
+ result.candidates[0].verification === 'verified')
2040
+ result.exact = result.candidates[0];
2041
+ return result;
2042
+ }
2043
+ async function describeClaudeCandidate(filePath, sessionId, cwds, processStartedAtMs, preserveMissing = true) {
2044
+ const candidate = {
2045
+ provider: 'claude', sessionId, filePath, ...(cwds[0] ? { cwd: cwds[0] } : {}), verification: 'unknown', readable: false
2046
+ };
2047
+ let file;
2048
+ try {
2049
+ file = await openFile(filePath, 'r');
2050
+ const info = await file.stat();
2051
+ if (!info.isFile() || (processStartedAtMs !== undefined && info.mtimeMs + CLAUDE_TRANSCRIPT_START_SKEW_MS < processStartedAtMs))
2052
+ return null;
2053
+ candidate.updatedAt = info.mtime.toISOString();
2054
+ candidate.readable = true;
2055
+ const offsets = info.size > CLAUDE_CANDIDATE_SAMPLE_BYTES ? [0, Math.max(CLAUDE_CANDIDATE_SAMPLE_BYTES, info.size - CLAUDE_CANDIDATE_SAMPLE_BYTES)] : [0];
2056
+ let otherCwd = false;
2057
+ let sidechain = false;
2058
+ for (const offset of offsets) {
2059
+ const buffer = Buffer.alloc(Math.min(CLAUDE_CANDIDATE_SAMPLE_BYTES, info.size - offset));
2060
+ const count = await readExactly(file, buffer, offset);
2061
+ for (const line of buffer.subarray(0, count).toString('utf8').split('\n')) {
2062
+ try {
2063
+ const row = JSON.parse(line);
2064
+ if (row.sessionId !== sessionId || typeof row.cwd !== 'string')
2065
+ continue;
2066
+ if (row.isSidechain === true) {
2067
+ sidechain = true;
2068
+ continue;
2069
+ }
2070
+ if (cwds.includes(row.cwd))
2071
+ candidate.verification = 'verified';
2072
+ else
2073
+ otherCwd = true;
2074
+ }
2075
+ catch { /* Partial sample lines are unknown, never negative evidence. */ }
2076
+ }
2077
+ }
2078
+ if ((otherCwd || sidechain) && candidate.verification !== 'verified')
2079
+ return null;
2080
+ return candidate;
2081
+ }
2082
+ catch (error) {
2083
+ return !preserveMissing && error.code === 'ENOENT' ? null : candidate;
2084
+ }
2085
+ finally {
2086
+ await file?.close().catch(() => undefined);
2087
+ }
2088
+ }
1818
2089
  export async function resolveClaudeTranscriptFromSessionRegistry(options) {
1819
2090
  if (!options.procStartTicks)
1820
2091
  return { kind: 'unavailable' };